Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
718
apps/ios/Sources/Design/AgentProDreamingDestination.swift
Normal file
718
apps/ios/Sources/Design/AgentProDreamingDestination.swift
Normal file
@@ -0,0 +1,718 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct AgentProDreamingDestination: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let overview: AgentOverviewSnapshot?
|
||||
let gatewayConnected: Bool
|
||||
let overviewLoading: Bool
|
||||
let dreamingValue: String
|
||||
let dreamingDetail: String
|
||||
let dreamingColor: Color
|
||||
let refresh: () async -> Void
|
||||
@State private var selectedDreamDiaryDayID: String?
|
||||
@State private var dreamActionBusy: DreamAction?
|
||||
@State private var dreamActionStatusText: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
self.detailSummaryCard(
|
||||
icon: "moon",
|
||||
title: "Dreaming",
|
||||
value: self.dreamingValue,
|
||||
detail: self.dreamingDetail,
|
||||
color: self.dreamingColor)
|
||||
self.dreamingTotalsCard
|
||||
self.dreamingActionsCard
|
||||
self.dreamDiaryCard
|
||||
self.dreamingEntriesList(
|
||||
title: "Promoted Entries",
|
||||
entries: self.overview?.dreaming?.promotedEntries ?? [],
|
||||
emptyTitle: "No promoted entries",
|
||||
emptyDetail: "Dreaming has not promoted durable memory entries yet.")
|
||||
self.dreamingEntriesList(
|
||||
title: "Signal Entries",
|
||||
entries: self.overview?.dreaming?.signalEntries ?? [],
|
||||
emptyTitle: "No signal entries",
|
||||
emptyDetail: "No recent recall, daily, grounded, or phase signals were reported.")
|
||||
self.dreamingEntriesList(
|
||||
title: "Short-Term Recall",
|
||||
entries: self.overview?.dreaming?.shortTermEntries ?? [],
|
||||
emptyTitle: "No short-term entries",
|
||||
emptyDetail: "The short-term dreaming store is empty.")
|
||||
self.dreamingPhasesCard
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refresh()
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Dreaming")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if let headerLeadingAction {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Dreaming",
|
||||
subtitle: self.dreamingDetail,
|
||||
titleFont: OpenClawType.title3SemiBold,
|
||||
subtitleFont: OpenClawType.subheadMedium)
|
||||
{
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private enum DreamAction: String, CaseIterable, Identifiable {
|
||||
case backfill
|
||||
case repair
|
||||
case dedupe
|
||||
|
||||
var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .backfill: "Backfill"
|
||||
case .repair: "Repair"
|
||||
case .dedupe: "Dedupe"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .backfill: "book.pages"
|
||||
case .repair: "wrench.and.screwdriver"
|
||||
case .dedupe: "square.stack.3d.down.right"
|
||||
}
|
||||
}
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
case .backfill: "doctor.memory.backfillDreamDiary"
|
||||
case .repair: "doctor.memory.repairDreamingArtifacts"
|
||||
case .dedupe: "doctor.memory.dedupeDreamDiary"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detailSummaryCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
ProCard {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(OpenClawType.headline)
|
||||
Text(detail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: value, color: color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var dreamingTotalsCard: some View {
|
||||
ProCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Memory State")
|
||||
.font(OpenClawType.headline)
|
||||
Spacer()
|
||||
ProValuePill(value: self.dreamingValue, color: self.dreamingColor)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
self.detailMetric(
|
||||
label: "Short-term",
|
||||
value: Self.compactNumber(self.overview?.dreaming?.shortTermCount ?? 0))
|
||||
self.detailMetric(
|
||||
label: "Signals",
|
||||
value: Self.compactNumber(self.overview?.dreaming?.totalSignalCount ?? 0))
|
||||
self.detailMetric(
|
||||
label: "Promoted",
|
||||
value: Self.compactNumber(self.overview?.dreaming?.promotedToday ?? 0))
|
||||
}
|
||||
if let storeError = self.normalized(self.overview?.dreaming?.storeError) {
|
||||
Text(storeError)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var dreamingActionsCard: some View {
|
||||
ProCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Maintenance")
|
||||
.font(OpenClawType.headline)
|
||||
Text("Refresh reads live state. Maintenance actions update the gateway diary/artifacts.")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
Task { await self.refresh() }
|
||||
} label: {
|
||||
Image(systemName: self.overviewLoading ? "hourglass" : "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(self.overviewLoading)
|
||||
.accessibilityLabel("Refresh dreaming")
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(DreamAction.allCases) { action in
|
||||
Button {
|
||||
Task { await self.runDreamAction(action) }
|
||||
} label: {
|
||||
Label(action.title, systemImage: self.dreamActionBusy == action ? "hourglass" : action.icon)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!self.gatewayConnected || self.dreamActionBusy != nil)
|
||||
}
|
||||
}
|
||||
|
||||
if let dreamActionStatusText {
|
||||
Text(dreamActionStatusText)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var dreamDiaryCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Dream Diary")
|
||||
ProCard(padding: 0) {
|
||||
if let diary = self.overview?.dreamDiary {
|
||||
if diary.found, let content = self.normalizedMultiline(diary.content) {
|
||||
let days = Self.dreamDiaryDays(from: content)
|
||||
let selectedDay = self.selectedDreamDiaryDay(from: days)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
ProIconBadge(systemName: "book.pages", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(diary.path)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
Text(self.dreamDiaryUpdatedLabel(diary))
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if !days.isEmpty {
|
||||
self.dreamDiaryDayMenu(days: days, selectedDay: selectedDay)
|
||||
}
|
||||
}
|
||||
if let selectedDay {
|
||||
self.dreamDiaryDayView(selectedDay)
|
||||
} else {
|
||||
self.emptyDetailRow(
|
||||
icon: "calendar.badge.exclamationmark",
|
||||
title: "No day entries",
|
||||
detail: "The diary is present, but it does not contain dated Dream Diary blocks.")
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
} else {
|
||||
self.emptyDetailRow(
|
||||
icon: "book.closed",
|
||||
title: diary.found ? "Dream diary is empty" : "No dream diary yet",
|
||||
detail: diary.found
|
||||
? "\(diary.path) exists but has no readable content."
|
||||
: "The gateway did not find DREAMS.md or dreams.md in the active agent workspace.")
|
||||
.padding(14)
|
||||
}
|
||||
} else {
|
||||
self.emptyDetailRow(
|
||||
icon: "book.closed",
|
||||
title: self.gatewayConnected ? "Diary unavailable" : "Dreaming unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "The gateway did not return dream diary content."
|
||||
: "Connect a gateway to read dream diary entries.")
|
||||
.padding(14)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private func dreamDiaryDayMenu(days: [DreamDiaryDay], selectedDay: DreamDiaryDay?) -> some View {
|
||||
Menu {
|
||||
ForEach(Array(days.reversed())) { day in
|
||||
Button {
|
||||
self.selectedDreamDiaryDayID = day.id
|
||||
} label: {
|
||||
Label(
|
||||
day.title,
|
||||
systemImage: day.id == selectedDay?.id ? "checkmark.circle.fill" : "calendar")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "calendar")
|
||||
Text(selectedDay?.title ?? "Day")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 34)
|
||||
.background(Color.primary.opacity(0.055), in: Capsule())
|
||||
}
|
||||
.accessibilityLabel("Dream diary day")
|
||||
}
|
||||
|
||||
private func dreamDiaryDayView(_ day: DreamDiaryDay) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(day.title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text("\(day.entryCount) \(day.entryCount == 1 ? "entry" : "entries")")
|
||||
.font(OpenClawType.caption2SemiBold)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
}
|
||||
Text(day.body)
|
||||
.font(OpenClawType.monoSmall)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(120)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
Color.primary.opacity(0.045),
|
||||
in: RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous))
|
||||
}
|
||||
|
||||
private func selectedDreamDiaryDay(from days: [DreamDiaryDay]) -> DreamDiaryDay? {
|
||||
if let selectedDreamDiaryDayID,
|
||||
let match = days.first(where: { $0.id == selectedDreamDiaryDayID })
|
||||
{
|
||||
return match
|
||||
}
|
||||
return days.last
|
||||
}
|
||||
|
||||
private func dreamingEntriesList(
|
||||
title: String,
|
||||
entries: [DreamingEntryLite],
|
||||
emptyTitle: String,
|
||||
emptyDetail: String) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: title)
|
||||
ProCard(padding: 0) {
|
||||
if entries.isEmpty {
|
||||
self.emptyDetailRow(
|
||||
icon: "doc.text.magnifyingglass",
|
||||
title: emptyTitle,
|
||||
detail: self.gatewayConnected ? emptyDetail : "Connect a gateway to load dreaming entries.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
|
||||
self.dreamingEntryRow(entry)
|
||||
if index < entries.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private func dreamingEntryRow(_ entry: DreamingEntryLite) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: "text.page", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.dreamingEntryTitle(entry))
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
Text(entry.snippet)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(4)
|
||||
.textSelection(.enabled)
|
||||
Text(self.dreamingEntryDetail(entry))
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text("\(entry.totalSignalCount)")
|
||||
.font(OpenClawType.caption2SemiBold)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
private var dreamingPhasesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Phases")
|
||||
ProCard(padding: 0) {
|
||||
let phases = self.dreamingPhases
|
||||
if phases.isEmpty {
|
||||
self.emptyDetailRow(
|
||||
icon: "moon.zzz",
|
||||
title: self.gatewayConnected ? "No phase status" : "Dreaming unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "The gateway did not return dreaming phase details."
|
||||
: "Connect a gateway to load dreaming phases.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(phases.enumerated()), id: \.element.id) { index, phase in
|
||||
self.dreamingPhaseRow(phase)
|
||||
if index < phases.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var dreamingPhases: [DreamingPhaseRow] {
|
||||
let phaseOrder = ["light", "deep", "rem"]
|
||||
let phases = self.overview?.dreaming?.phases ?? [:]
|
||||
return phaseOrder.compactMap { id in
|
||||
guard let phase = phases[id] else { return nil }
|
||||
return DreamingPhaseRow(id: id, title: id.capitalized, status: phase)
|
||||
}
|
||||
}
|
||||
|
||||
private func dreamingPhaseRow(_ phase: DreamingPhaseRow) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: phase.status.enabled == false ? "pause.circle" : "moon.stars",
|
||||
color: phase.status.enabled == false ? .secondary : OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(phase.title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(self.dreamingPhaseDetail(phase.status))
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let cron = self.normalized(phase.status.cron) {
|
||||
Text(cron)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.dreamingPhaseState(phase.status))
|
||||
.font(OpenClawType.caption2SemiBold)
|
||||
.foregroundStyle(phase.status.managedCronPresent == true ? OpenClawBrand.accent : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
private func emptyDetailRow(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(detail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func detailMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(label)
|
||||
.font(OpenClawType.caption2Medium)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(
|
||||
Color.primary.opacity(0.055),
|
||||
in: RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous))
|
||||
}
|
||||
|
||||
private func dreamingEntryTitle(_ entry: DreamingEntryLite) -> String {
|
||||
let path = entry.path.split(separator: "/").last.map(String.init) ?? entry.path
|
||||
return "\(path):\(entry.startLine)"
|
||||
}
|
||||
|
||||
private func dreamingEntryDetail(_ entry: DreamingEntryLite) -> String {
|
||||
let parts = [
|
||||
entry.promotedAt.map { "promoted \($0)" },
|
||||
entry.lastRecalledAt.map { "recalled \($0)" },
|
||||
"\(entry.recallCount) recalls",
|
||||
"\(entry.groundedCount) grounded",
|
||||
].compactMap(\.self)
|
||||
return parts.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private func dreamingPhaseDetail(_ phase: DreamingPhaseStatusLite) -> String {
|
||||
if let nextRunAtMs = phase.nextRunAtMs {
|
||||
return "Next cycle \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
|
||||
}
|
||||
if phase.managedCronPresent == true {
|
||||
return "Managed cron is installed."
|
||||
}
|
||||
return "Managed cron is not installed."
|
||||
}
|
||||
|
||||
private func dreamingPhaseState(_ phase: DreamingPhaseStatusLite) -> String {
|
||||
if phase.enabled == false { return "off" }
|
||||
return phase.managedCronPresent == true ? "scheduled" : "setup"
|
||||
}
|
||||
|
||||
private func dreamDiaryUpdatedLabel(_ diary: DreamDiaryLite) -> String {
|
||||
guard let updatedAtMs = diary.updatedAtMs else { return "No update timestamp" }
|
||||
return "Updated \(Self.relativeTime(fromMilliseconds: updatedAtMs))"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runDreamAction(_ action: DreamAction) async {
|
||||
guard self.gatewayConnected, self.dreamActionBusy == nil else { return }
|
||||
self.dreamActionBusy = action
|
||||
self.dreamActionStatusText = nil
|
||||
defer { self.dreamActionBusy = nil }
|
||||
|
||||
do {
|
||||
let data = try await self.appModel.operatorSession.request(
|
||||
method: action.method,
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 30)
|
||||
self.dreamActionStatusText = Self.dreamActionSummary(action: action, data: data)
|
||||
await self.refresh()
|
||||
} catch {
|
||||
self.dreamActionStatusText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private static func dreamActionSummary(action: DreamAction, data: Data) -> String {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return "\(action.title) complete."
|
||||
}
|
||||
let written = json["written"] as? Int
|
||||
let replaced = json["replaced"] as? Int
|
||||
let removed = json["removedEntries"] as? Int
|
||||
let changed = json["changed"] as? Bool
|
||||
let parts = [
|
||||
written.map { "\($0) written" },
|
||||
replaced.map { "\($0) replaced" },
|
||||
removed.map { "\($0) removed" },
|
||||
changed.map { $0 ? "artifacts repaired" : "no repair needed" },
|
||||
].compactMap(\.self)
|
||||
if parts.isEmpty {
|
||||
return "\(action.title) complete."
|
||||
}
|
||||
return "\(action.title): \(parts.joined(separator: ", "))."
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func normalizedMultiline(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func compactNumber(_ value: Int) -> String {
|
||||
value.formatted(.number.notation(.compactName))
|
||||
}
|
||||
|
||||
private static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
|
||||
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
|
||||
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
|
||||
}
|
||||
|
||||
private static func dreamDiaryDays(from content: String) -> [DreamDiaryDay] {
|
||||
let inner = Self.dreamDiaryInnerContent(content)
|
||||
let separatorBlocks = inner
|
||||
.components(separatedBy: "\n---")
|
||||
.flatMap { $0.components(separatedBy: "\r\n---") }
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
let blocks = separatorBlocks.count > 1 ? separatorBlocks : Self.splitDiaryBlocksByDateLine(inner)
|
||||
let parsedBlocks = blocks.enumerated().map { index, block in
|
||||
Self.dreamDiaryBlock(from: block, index: index)
|
||||
}.filter(\.hasDatedEntry)
|
||||
return Self.mergeDiaryBlocksByDay(parsedBlocks)
|
||||
}
|
||||
|
||||
private static func dreamDiaryInnerContent(_ content: String) -> String {
|
||||
let start = "<!-- openclaw:dreaming:diary:start -->"
|
||||
let end = "<!-- openclaw:dreaming:diary:end -->"
|
||||
guard let startRange = content.range(of: start),
|
||||
let endRange = content.range(of: end, range: startRange.upperBound..<content.endIndex)
|
||||
else {
|
||||
return content
|
||||
}
|
||||
return String(content[startRange.upperBound..<endRange.lowerBound])
|
||||
}
|
||||
|
||||
private static func dreamDiaryBlock(from block: String, index: Int) -> DreamDiaryDay {
|
||||
let rawLines = block.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
|
||||
let dateLineIndex = rawLines.firstIndex { line in
|
||||
Self.isDiaryDateLine(line)
|
||||
}
|
||||
let markerDay = rawLines.compactMap(Self.backfillDay).first
|
||||
let rawTitle = dateLineIndex.flatMap { Self.unwrappedEmphasis(rawLines[$0]) } ?? markerDay
|
||||
let title = rawTitle.map(Self.dayTitle) ?? markerDay ?? "Diary"
|
||||
let id = markerDay ?? Self.dayID(title)
|
||||
let bodyLines = rawLines.enumerated().compactMap { offset, line -> String? in
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if offset == dateLineIndex { return nil }
|
||||
if trimmed.hasPrefix("<!--") && trimmed.hasSuffix("-->") { return nil }
|
||||
if trimmed == "#" || trimmed == "# Dream Diary" { return nil }
|
||||
return line
|
||||
}
|
||||
let body = bodyLines
|
||||
.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return DreamDiaryDay(
|
||||
id: id.isEmpty ? "\(index)" : id,
|
||||
title: title,
|
||||
body: body.isEmpty ? "No diary prose for this day." : body,
|
||||
entryCount: 1,
|
||||
hasDatedEntry: rawTitle != nil)
|
||||
}
|
||||
|
||||
private static func mergeDiaryBlocksByDay(_ blocks: [DreamDiaryDay]) -> [DreamDiaryDay] {
|
||||
var ordered: [DreamDiaryDay] = []
|
||||
for block in blocks {
|
||||
if let existingIndex = ordered.firstIndex(where: { $0.title == block.title }) {
|
||||
let existing = ordered[existingIndex]
|
||||
ordered[existingIndex] = DreamDiaryDay(
|
||||
id: existing.id,
|
||||
title: existing.title,
|
||||
body: [existing.body, block.body].joined(separator: "\n\n---\n\n"),
|
||||
entryCount: existing.entryCount + block.entryCount,
|
||||
hasDatedEntry: true)
|
||||
} else {
|
||||
ordered.append(block)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
private static func splitDiaryBlocksByDateLine(_ content: String) -> [String] {
|
||||
var blocks: [String] = []
|
||||
var current: [String] = []
|
||||
for line in content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
|
||||
if Self.isDiaryDateLine(line), !current.isEmpty {
|
||||
blocks.append(current.joined(separator: "\n"))
|
||||
current = []
|
||||
}
|
||||
current.append(line)
|
||||
}
|
||||
if !current.isEmpty {
|
||||
blocks.append(current.joined(separator: "\n"))
|
||||
}
|
||||
return blocks
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private static func isDiaryDateLine(_ line: String) -> Bool {
|
||||
guard let value = unwrappedEmphasis(line) else { return false }
|
||||
let monthNames = "January|February|March|April|May|June|July|August|September|October|November|December"
|
||||
let monthDatePattern = #"\b("# + monthNames + #")\s+\d{1,2},\s+\d{4}\b"#
|
||||
let isoDatePattern = #"\b\d{4}-\d{2}-\d{2}\b"#
|
||||
return value.range(
|
||||
of: "\(monthDatePattern)|\(isoDatePattern)",
|
||||
options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private static func dayTitle(_ rawTitle: String) -> String {
|
||||
let noTime = rawTitle.replacingOccurrences(
|
||||
of: #"\s+at\s+\d{1,2}:\d{2}.*$"#,
|
||||
with: "",
|
||||
options: .regularExpression)
|
||||
return noTime.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func dayID(_ title: String) -> String {
|
||||
title.lowercased()
|
||||
.replacingOccurrences(of: #"[^a-z0-9]+"#, with: "-", options: .regularExpression)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
||||
}
|
||||
|
||||
private static func unwrappedEmphasis(_ line: String) -> String? {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("*"), trimmed.hasSuffix("*"), trimmed.count > 2 else { return nil }
|
||||
return String(trimmed.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
private static func backfillDay(_ line: String) -> String? {
|
||||
guard let range = line.range(of: #"day=\d{4}-\d{2}-\d{2}"#, options: .regularExpression) else {
|
||||
return nil
|
||||
}
|
||||
return String(line[range].dropFirst(4))
|
||||
}
|
||||
}
|
||||
|
||||
private struct DreamDiaryDay: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let body: String
|
||||
let entryCount: Int
|
||||
let hasDatedEntry: Bool
|
||||
}
|
||||
378
apps/ios/Sources/Design/AgentProModels.swift
Normal file
378
apps/ios/Sources/Design/AgentProModels.swift
Normal file
@@ -0,0 +1,378 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
|
||||
enum AgentProValueReader {
|
||||
static func intValue(_ value: AnyCodable?) -> Int? {
|
||||
switch value?.value {
|
||||
case let int as Int: int
|
||||
case let double as Double where double.isFinite: Int(double)
|
||||
case let string as String: Int(string)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
static func doubleValue(_ value: AnyCodable?) -> Double? {
|
||||
switch value?.value {
|
||||
case let double as Double where double.isFinite: double
|
||||
case let int as Int: Double(int)
|
||||
case let string as String: Double(string)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentOverviewSnapshot {
|
||||
let skills: SkillStatusReportLite?
|
||||
let presence: [PresenceEntry]
|
||||
let cronStatus: CronStatusLite?
|
||||
let cronJobs: [CronJob]
|
||||
let dreaming: DreamingStatusLite?
|
||||
let dreamDiary: DreamDiaryLite?
|
||||
let usage: CostUsageSummaryLite?
|
||||
let activeAgentId: String
|
||||
let agentSkillFilter: [String]?
|
||||
let loadedAt: Date
|
||||
|
||||
var hasAnyLiveData: Bool {
|
||||
self.skills != nil
|
||||
|| !self.presence.isEmpty
|
||||
|| self.cronStatus != nil
|
||||
|| !self.cronJobs.isEmpty
|
||||
|| self.dreaming != nil
|
||||
|| self.dreamDiary != nil
|
||||
|| self.usage != nil
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillStatusReportLite: Decodable {
|
||||
let workspaceDir: String?
|
||||
let managedSkillsDir: String?
|
||||
let agentId: String?
|
||||
let agentSkillFilter: [String]?
|
||||
let skills: [SkillStatusEntryLite]
|
||||
|
||||
var totalCount: Int {
|
||||
self.skills.count
|
||||
}
|
||||
|
||||
var enabledCount: Int {
|
||||
self.skills.count {
|
||||
$0.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
var blockedCount: Int {
|
||||
self.skills.count {
|
||||
$0.blockedByAllowlist == true || $0.blockedByAgentFilter == true
|
||||
}
|
||||
}
|
||||
|
||||
var missingRequirementCount: Int {
|
||||
self.skills.count {
|
||||
$0.hasMissingRequirements
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillStatusEntryLite: Decodable {
|
||||
let name: String
|
||||
let description: String?
|
||||
let source: String?
|
||||
let filePath: String?
|
||||
let skillKey: String?
|
||||
let primaryEnv: String?
|
||||
let emoji: String?
|
||||
let homepage: String?
|
||||
let disabled: Bool?
|
||||
let blockedByAllowlist: Bool?
|
||||
let blockedByAgentFilter: Bool?
|
||||
let missing: SkillStatusMissingLite?
|
||||
let install: [SkillInstallOptionLite]?
|
||||
|
||||
var displayName: String {
|
||||
if let emoji, !emoji.isEmpty {
|
||||
return "\(emoji) \(self.name)"
|
||||
}
|
||||
return self.name
|
||||
}
|
||||
|
||||
var effectiveSkillKey: String {
|
||||
let trimmed = (self.skillKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? self.name : trimmed
|
||||
}
|
||||
|
||||
var isGloballyEnabled: Bool {
|
||||
self.disabled != true
|
||||
}
|
||||
|
||||
var isEnabled: Bool {
|
||||
self.disabled != true
|
||||
&& self.blockedByAllowlist != true
|
||||
&& self.blockedByAgentFilter != true
|
||||
}
|
||||
|
||||
var hasMissingRequirements: Bool {
|
||||
guard let missing else { return false }
|
||||
return !missing.bins.isEmpty
|
||||
|| !missing.env.isEmpty
|
||||
|| !missing.config.isEmpty
|
||||
|| !missing.os.isEmpty
|
||||
}
|
||||
|
||||
var missingSummary: String? {
|
||||
guard let missing else { return nil }
|
||||
let values = [
|
||||
missing.bins,
|
||||
missing.env,
|
||||
missing.config,
|
||||
missing.os,
|
||||
].flatMap(\.self)
|
||||
return values.isEmpty ? nil : values.prefix(3).joined(separator: ", ")
|
||||
}
|
||||
|
||||
var installSummary: String? {
|
||||
guard let option = self.install?.first else { return nil }
|
||||
return option.label
|
||||
}
|
||||
|
||||
var missingBins: [String] {
|
||||
self.missing?.bins ?? []
|
||||
}
|
||||
|
||||
var homepageURL: URL? {
|
||||
guard let homepage else { return nil }
|
||||
return URL(string: homepage)
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillInstallOptionLite: Decodable {
|
||||
let id: String?
|
||||
let kind: String?
|
||||
let label: String
|
||||
let bins: [String]?
|
||||
}
|
||||
|
||||
struct SkillUpdateParams: Encodable {
|
||||
let skillKey: String
|
||||
var enabled: Bool?
|
||||
var apiKey: String?
|
||||
}
|
||||
|
||||
struct SkillInstallParams: Encodable {
|
||||
let name: String
|
||||
let installId: String
|
||||
let timeoutMs: Int
|
||||
}
|
||||
|
||||
struct SkillInstallResultLite: Decodable {
|
||||
let message: String?
|
||||
}
|
||||
|
||||
struct ClawHubSearchParams: Encodable {
|
||||
let query: String?
|
||||
let limit: Int
|
||||
}
|
||||
|
||||
struct ClawHubSearchResponseLite: Decodable {
|
||||
let results: [ClawHubSearchResultLite]
|
||||
}
|
||||
|
||||
struct ClawHubSearchResultLite: Decodable {
|
||||
let slug: String
|
||||
let displayName: String
|
||||
let summary: String?
|
||||
let version: String?
|
||||
}
|
||||
|
||||
struct ClawHubInstallParams: Encodable {
|
||||
let source = "clawhub"
|
||||
let slug: String
|
||||
}
|
||||
|
||||
struct CronRunParams: Encodable {
|
||||
let id: String
|
||||
let mode: String
|
||||
}
|
||||
|
||||
struct CronUpdatePatch: Encodable {
|
||||
let enabled: Bool
|
||||
}
|
||||
|
||||
struct CronUpdateParams: Encodable {
|
||||
let id: String
|
||||
let patch: CronUpdatePatch
|
||||
}
|
||||
|
||||
struct SkillStatusMissingLite: Decodable {
|
||||
let bins: [String]
|
||||
let env: [String]
|
||||
let config: [String]
|
||||
let os: [String]
|
||||
}
|
||||
|
||||
struct CronStatusLite: Decodable {
|
||||
let enabled: Bool
|
||||
let jobs: Int
|
||||
let nextwakeatms: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case enabled
|
||||
case jobs
|
||||
case nextwakeatms = "nextWakeAtMs"
|
||||
}
|
||||
}
|
||||
|
||||
struct CronJobsListLite: Decodable {
|
||||
let jobs: [CronJob]
|
||||
let total: Int?
|
||||
}
|
||||
|
||||
struct DreamingStatusEnvelope: Decodable {
|
||||
let dreaming: DreamingStatusLite?
|
||||
}
|
||||
|
||||
struct DreamingStatusLite: Decodable {
|
||||
let enabled: Bool
|
||||
let shortTermCount: Int?
|
||||
let totalSignalCount: Int?
|
||||
let promotedToday: Int?
|
||||
let storeError: String?
|
||||
let shortTermEntries: [DreamingEntryLite]?
|
||||
let signalEntries: [DreamingEntryLite]?
|
||||
let promotedEntries: [DreamingEntryLite]?
|
||||
let phases: [String: DreamingPhaseStatusLite]?
|
||||
|
||||
var nextRunAtMs: Int? {
|
||||
self.phases?.values
|
||||
.compactMap(\.nextRunAtMs)
|
||||
.min()
|
||||
}
|
||||
}
|
||||
|
||||
struct DreamingEntryLite: Decodable, Identifiable {
|
||||
let key: String
|
||||
let path: String
|
||||
let startLine: Int
|
||||
let endLine: Int
|
||||
let snippet: String
|
||||
let recallCount: Int
|
||||
let dailyCount: Int
|
||||
let groundedCount: Int
|
||||
let totalSignalCount: Int
|
||||
let lightHits: Int
|
||||
let remHits: Int
|
||||
let phaseHitCount: Int
|
||||
let promotedAt: String?
|
||||
let lastRecalledAt: String?
|
||||
|
||||
var id: String {
|
||||
"\(self.key):\(self.path):\(self.startLine):\(self.endLine)"
|
||||
}
|
||||
}
|
||||
|
||||
struct DreamDiaryLite: Decodable {
|
||||
let agentId: String
|
||||
let found: Bool
|
||||
let path: String
|
||||
let content: String?
|
||||
let updatedAtMs: Int?
|
||||
}
|
||||
|
||||
struct DreamingPhaseStatusLite: Decodable {
|
||||
let enabled: Bool?
|
||||
let cron: String?
|
||||
let managedCronPresent: Bool?
|
||||
let nextRunAtMs: Int?
|
||||
}
|
||||
|
||||
struct DreamingPhaseRow: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let status: DreamingPhaseStatusLite
|
||||
}
|
||||
|
||||
struct ConfigSnapshotLite: Decodable {
|
||||
let hash: String?
|
||||
let config: ConfigRootLite?
|
||||
|
||||
func agentConfig(id: String) -> AgentConfigLite? {
|
||||
self.config?.agents?.list?.first { $0.id == id }
|
||||
}
|
||||
|
||||
func effectiveSkillFilter(agentId: String) -> [String]? {
|
||||
if let agentSkills = self.agentConfig(id: agentId)?.skills {
|
||||
return agentSkills
|
||||
}
|
||||
return self.config?.agents?.defaults?.skills
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigRootLite: Decodable {
|
||||
let agents: AgentsConfigLite?
|
||||
}
|
||||
|
||||
struct AgentsConfigLite: Decodable {
|
||||
let defaults: AgentDefaultsConfigLite?
|
||||
let list: [AgentConfigLite]?
|
||||
}
|
||||
|
||||
struct AgentDefaultsConfigLite: Decodable {
|
||||
let skills: [String]?
|
||||
}
|
||||
|
||||
struct AgentConfigLite: Decodable {
|
||||
let id: String
|
||||
let skills: [String]?
|
||||
}
|
||||
|
||||
struct ConfigPatchParams: Encodable {
|
||||
let raw: String
|
||||
let baseHash: String
|
||||
let replacePaths: [String]?
|
||||
|
||||
init(raw: String, baseHash: String, replacePaths: [String]? = nil) {
|
||||
self.raw = raw
|
||||
self.baseHash = baseHash
|
||||
self.replacePaths = replacePaths
|
||||
}
|
||||
}
|
||||
|
||||
enum SkillMutationError: LocalizedError {
|
||||
case liveGatewayUnavailable
|
||||
case missingConfigHash
|
||||
case invalidPatchPayload
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .liveGatewayUnavailable:
|
||||
"Connect a live gateway to edit agent skills."
|
||||
case .missingConfigHash:
|
||||
"Config hash missing; refresh and retry."
|
||||
case .invalidPatchPayload:
|
||||
"Could not encode the skill config update."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CostUsageSummaryLite: Decodable {
|
||||
let updatedAt: Int?
|
||||
let days: Int?
|
||||
let daily: [CostUsageDailyEntryLite]?
|
||||
let totals: [String: AnyCodable]?
|
||||
let cacheStatus: [String: AnyCodable]?
|
||||
|
||||
var totalCost: Double? {
|
||||
AgentProValueReader.doubleValue(self.totals?["totalCost"])
|
||||
}
|
||||
|
||||
var totalTokens: Int? {
|
||||
AgentProValueReader.intValue(self.totals?["totalTokens"])
|
||||
}
|
||||
}
|
||||
|
||||
struct CostUsageDailyEntryLite: Decodable {
|
||||
let date: String
|
||||
let totalTokens: Int?
|
||||
let totalCost: Double?
|
||||
}
|
||||
373
apps/ios/Sources/Design/AgentProNodesDestination.swift
Normal file
373
apps/ios/Sources/Design/AgentProNodesDestination.swift
Normal file
@@ -0,0 +1,373 @@
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct AgentProNodesDestination: View {
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let overview: AgentOverviewSnapshot?
|
||||
let gatewayConnected: Bool
|
||||
let agentCount: Int
|
||||
let instancesValue: String
|
||||
let instancesDetail: String
|
||||
let instancesColor: Color
|
||||
let refresh: () async -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
self.summaryCard
|
||||
self.totalsCard
|
||||
self.nodesList
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refresh()
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Instances")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if let headerLeadingAction {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Instances",
|
||||
subtitle: self.instancesDetail,
|
||||
titleFont: OpenClawType.title3SemiBold,
|
||||
subtitleFont: OpenClawType.subheadMedium)
|
||||
{
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
ProCard {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "display", color: self.instancesColor)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Instances")
|
||||
.font(OpenClawType.headline)
|
||||
Text(self.instancesDetail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.instancesValue, color: self.instancesColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var totalsCard: some View {
|
||||
ProCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Presence")
|
||||
.font(OpenClawType.headline)
|
||||
Spacer()
|
||||
ProValuePill(value: self.instancesValue, color: self.instancesColor)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
self.detailMetric(label: "Connected", value: "\(self.overview?.presence.count ?? 0)")
|
||||
self.detailMetric(label: "Agents", value: "\(self.agentCount)")
|
||||
self.detailMetric(label: "Gateway", value: self.gatewayConnected ? "online" : "offline")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var nodesList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Connected Instances")
|
||||
ProCard(padding: 0) {
|
||||
let nodes = self.sortedPresenceEntries
|
||||
if nodes.isEmpty {
|
||||
self.emptyRow(
|
||||
icon: "display",
|
||||
title: self.gatewayConnected ? "No instances connected" : "Instances unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "The gateway did not report any system presence entries."
|
||||
: "Connect a gateway to inspect connected instances.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(nodes.enumerated()), id: \.element.presenceKey) { index, entry in
|
||||
NavigationLink {
|
||||
self.nodeDetail(entry)
|
||||
} label: {
|
||||
self.nodePresenceRow(entry, showsChevron: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if index < nodes.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var sortedPresenceEntries: [PresenceEntry] {
|
||||
(self.overview?.presence ?? [])
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.ts != rhs.ts { return lhs.ts > rhs.ts }
|
||||
return (Self.presenceLabel(lhs) ?? lhs.presenceKey)
|
||||
.localizedCaseInsensitiveCompare(Self.presenceLabel(rhs) ?? rhs.presenceKey) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func nodePresenceRow(_ entry: PresenceEntry, showsChevron: Bool = false) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(Self.presenceLabel(entry) ?? "Instance")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
Text(Self.presenceDetail(entry))
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let meta = Self.presenceMeta(entry) {
|
||||
Text(meta)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(Self.presenceState(entry))
|
||||
.font(OpenClawType.caption2SemiBold)
|
||||
.foregroundStyle(Self.presenceColor(entry))
|
||||
.lineLimit(1)
|
||||
if showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(OpenClawType.caption2Bold)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
private func nodeDetail(_ entry: PresenceEntry) -> some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
ProCard {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(Self.presenceLabel(entry) ?? "Instance")
|
||||
.font(OpenClawType.headline)
|
||||
Text(Self.presenceDetail(entry))
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: Self.presenceState(entry), color: Self.presenceColor(entry))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
ProCard {
|
||||
VStack(spacing: 0) {
|
||||
self.nodeDetailRow("Instance", value: entry.instanceid)
|
||||
Divider()
|
||||
self.nodeDetailRow("Device", value: entry.deviceid)
|
||||
Divider()
|
||||
self.nodeDetailRow("Host", value: entry.host)
|
||||
Divider()
|
||||
self.nodeDetailRow("IP", value: entry.ip)
|
||||
Divider()
|
||||
self.nodeDetailRow("Platform", value: entry.platform)
|
||||
Divider()
|
||||
self.nodeDetailRow("Version", value: entry.version)
|
||||
Divider()
|
||||
self.nodeDetailRow("Mode", value: entry.mode)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
self.nodeListCard(title: "Scopes", values: entry.scopes ?? [])
|
||||
self.nodeListCard(title: "Roles", values: entry.roles ?? [])
|
||||
self.nodeListCard(title: "Tags", values: entry.tags ?? [])
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle(Self.presenceLabel(entry) ?? "Instance")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func nodeDetailRow(_ title: String, value: String?) -> some View {
|
||||
let normalized = Self.normalized(value) ?? "n/a"
|
||||
return HStack(spacing: 10) {
|
||||
Text(title)
|
||||
.font(OpenClawType.subhead)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text(normalized)
|
||||
.font(OpenClawType.subhead)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Button {
|
||||
UIPasteboard.general.string = normalized
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(normalized == "n/a")
|
||||
.accessibilityLabel("Copy \(title)")
|
||||
}
|
||||
.font(OpenClawType.subhead)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func nodeListCard(title: String, values: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: title)
|
||||
ProCard {
|
||||
if values.isEmpty {
|
||||
Text("None reported.")
|
||||
.font(OpenClawType.subhead)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(values, id: \.self) { value in
|
||||
Text(value)
|
||||
.font(OpenClawType.monoSmall)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private func detailMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(label)
|
||||
.font(OpenClawType.caption2Medium)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(
|
||||
Color.primary.opacity(0.055),
|
||||
in: RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous))
|
||||
}
|
||||
|
||||
private func emptyRow(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(detail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private static func presenceLabel(_ entry: PresenceEntry) -> String? {
|
||||
self.normalized(entry.host)
|
||||
?? self.normalized(entry.devicefamily)
|
||||
?? self.normalized(entry.platform)
|
||||
?? self.normalized(entry.mode)
|
||||
}
|
||||
|
||||
private static func presenceDetail(_ entry: PresenceEntry) -> String {
|
||||
let parts = [
|
||||
Self.normalized(entry.ip),
|
||||
Self.normalized(entry.platform),
|
||||
Self.normalized(entry.version),
|
||||
].compactMap(\.self)
|
||||
if !parts.isEmpty {
|
||||
return parts.joined(separator: " • ")
|
||||
}
|
||||
return Self.normalized(entry.text) ?? "Presence beacon received."
|
||||
}
|
||||
|
||||
private static func presenceMeta(_ entry: PresenceEntry) -> String? {
|
||||
let tags = (entry.tags ?? []).prefix(2).joined(separator: ", ")
|
||||
let scopesCount = entry.scopes?.count ?? 0
|
||||
let rolesCount = entry.roles?.count ?? 0
|
||||
let labels = [
|
||||
Self.normalized(entry.instanceid).map { "instance \($0)" },
|
||||
tags.isEmpty ? nil : tags,
|
||||
scopesCount > 0 ? "\(scopesCount) scopes" : nil,
|
||||
rolesCount > 0 ? "\(rolesCount) roles" : nil,
|
||||
].compactMap(\.self)
|
||||
return labels.isEmpty ? nil : labels.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private static func presenceState(_ entry: PresenceEntry) -> String {
|
||||
if let reason = normalized(entry.reason) {
|
||||
return reason
|
||||
}
|
||||
if let mode = Self.normalized(entry.mode) {
|
||||
return mode
|
||||
}
|
||||
return Self.relativeTime(fromMilliseconds: entry.ts)
|
||||
}
|
||||
|
||||
private static func presenceIcon(_ entry: PresenceEntry) -> String {
|
||||
let family = Self.normalized(entry.devicefamily)?.lowercased()
|
||||
if family?.contains("phone") == true { return "iphone" }
|
||||
if family?.contains("tablet") == true || family?.contains("pad") == true { return "ipad" }
|
||||
if family?.contains("desktop") == true || family?.contains("mac") == true { return "desktopcomputer" }
|
||||
return "display"
|
||||
}
|
||||
|
||||
private static func presenceColor(_ entry: PresenceEntry) -> Color {
|
||||
self.normalized(entry.reason) == nil ? OpenClawBrand.accent : OpenClawBrand.warn
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
|
||||
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
|
||||
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
|
||||
}
|
||||
}
|
||||
|
||||
extension PresenceEntry {
|
||||
fileprivate var presenceKey: String {
|
||||
self.instanceid
|
||||
?? self.deviceid
|
||||
?? self.host
|
||||
?? self.ip
|
||||
?? "\(self.ts)"
|
||||
}
|
||||
}
|
||||
180
apps/ios/Sources/Design/AgentProTab+Cron.swift
Normal file
180
apps/ios/Sources/Design/AgentProTab+Cron.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
var cronStatusCard: some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Scheduler")
|
||||
.font(OpenClawType.headline)
|
||||
Spacer()
|
||||
ProValuePill(
|
||||
value: self.overview?.cronStatus?.enabled == true ? "on" : "off",
|
||||
color: self.cronColor)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
let jobCount = self.overview?.cronStatus?.jobs
|
||||
?? self.overview?.cronJobs.count
|
||||
?? 0
|
||||
self.detailMetric(label: "Jobs", value: "\(jobCount)")
|
||||
self.detailMetric(label: "Next", value: self.cronNextRunLabel)
|
||||
}
|
||||
if let cronActionStatusText {
|
||||
Text(cronActionStatusText)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var cronNextRunLabel: String {
|
||||
guard let nextWakeAtMs = self.overview?.cronStatus?.nextwakeatms else { return "none" }
|
||||
return Self.relativeTime(fromMilliseconds: nextWakeAtMs)
|
||||
}
|
||||
|
||||
func cronJobsList(limit: Int?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Jobs")
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
let jobs = self.sortedCronJobs
|
||||
let visible = limit.map { Array(jobs.prefix($0)) } ?? jobs
|
||||
if visible.isEmpty {
|
||||
self.emptyCronRow
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(visible.enumerated()), id: \.element.id) { index, job in
|
||||
self.cronJobDetailRow(job)
|
||||
if index < visible.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var sortedCronJobs: [CronJob] {
|
||||
(self.overview?.cronJobs ?? [])
|
||||
.sorted { lhs, rhs in
|
||||
let lhsNext = AgentProValueReader.intValue(lhs.state["nextRunAtMs"])
|
||||
let rhsNext = AgentProValueReader.intValue(rhs.state["nextRunAtMs"])
|
||||
switch (lhsNext, rhsNext) {
|
||||
case let (lhsNext?, rhsNext?): return lhsNext < rhsNext
|
||||
case (_?, nil): return true
|
||||
case (nil, _?): return false
|
||||
case (nil, nil): return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cronJobDetailRow(_ job: CronJob) -> some View {
|
||||
let busy = self.cronActionBusyIDs.contains(job.id)
|
||||
return HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: job.enabled ? "clock.arrow.circlepath" : "pause.circle",
|
||||
color: job.enabled ? OpenClawBrand.accent : .secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(job.name)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
Text(self.cronJobDetail(job))
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
Text(self.cronScheduleSummary(job))
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await self.runCronJob(job) }
|
||||
} label: {
|
||||
Label("Run", systemImage: "play.fill")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.disabled(busy || !self.liveGatewayConnected)
|
||||
|
||||
Button {
|
||||
Task { await self.setCronJob(job, enabled: !job.enabled) }
|
||||
} label: {
|
||||
Label(job.enabled ? "Pause" : "Enable", systemImage: job.enabled ? "pause.fill" : "checkmark")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.disabled(busy || !self.liveGatewayConnected)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if busy {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text(self.cronJobState(job))
|
||||
.font(OpenClawType.caption2SemiBold)
|
||||
.foregroundStyle(job.enabled ? OpenClawBrand.accent : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runCronJob(_ job: CronJob) async {
|
||||
await self.runCronAction(job, success: "Queued \(job.name).") {
|
||||
let params = CronRunParams(id: job.id, mode: "force")
|
||||
_ = try await self.requestGateway(method: "cron.run", params: params, timeoutSeconds: 20)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func setCronJob(_ job: CronJob, enabled: Bool) async {
|
||||
await self.runCronAction(job, success: enabled ? "Enabled \(job.name)." : "Paused \(job.name).") {
|
||||
let params = CronUpdateParams(id: job.id, patch: CronUpdatePatch(enabled: enabled))
|
||||
_ = try await self.requestGateway(method: "cron.update", params: params, timeoutSeconds: 20)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runCronAction(
|
||||
_ job: CronJob,
|
||||
success: String,
|
||||
action: () async throws -> Void) async
|
||||
{
|
||||
guard self.liveGatewayConnected else { return }
|
||||
self.cronActionBusyIDs.insert(job.id)
|
||||
self.cronActionStatusText = nil
|
||||
defer { self.cronActionBusyIDs.remove(job.id) }
|
||||
do {
|
||||
try await action()
|
||||
self.cronActionStatusText = success
|
||||
await self.refreshOverview(force: true)
|
||||
} catch {
|
||||
self.cronActionStatusText = Self.skillMutationMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
func cronScheduleSummary(_ job: CronJob) -> String {
|
||||
guard let schedule = job.schedule.value as? [String: AnyCodable] else { return "Schedule configured" }
|
||||
if let expr = Self.stringValue(schedule["expr"]) {
|
||||
return "Cron \(expr)"
|
||||
}
|
||||
if let everyMs = AgentProValueReader.intValue(schedule["everyMs"]) {
|
||||
return "Every \(Self.duration(milliseconds: everyMs))"
|
||||
}
|
||||
if let kind = Self.stringValue(schedule["kind"]) {
|
||||
return kind
|
||||
}
|
||||
return "Schedule configured"
|
||||
}
|
||||
}
|
||||
217
apps/ios/Sources/Design/AgentProTab+Destinations.swift
Normal file
217
apps/ios/Sources/Design/AgentProTab+Destinations.swift
Normal file
@@ -0,0 +1,217 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
@ViewBuilder
|
||||
func destination(for route: AgentRoute) -> some View {
|
||||
switch route {
|
||||
case .agents:
|
||||
self.agentsDestination
|
||||
case .skills:
|
||||
self.skillsDestination
|
||||
case .instances:
|
||||
self.instancesDestination
|
||||
case .cron:
|
||||
self.cronDestination
|
||||
case .usage:
|
||||
self.usageDestination
|
||||
case .dreaming:
|
||||
self.dreamingDestination
|
||||
}
|
||||
}
|
||||
|
||||
var agentsDestination: some View {
|
||||
List {
|
||||
Section {
|
||||
if self.filteredAgents.isEmpty {
|
||||
self.emptyAgentsRow
|
||||
} else {
|
||||
ForEach(self.filteredAgents, id: \.id) { agent in
|
||||
self.agentRow(agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle(self.headerTitle)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.searchable(text: self.$agentSearchText, prompt: "Search agents")
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.font(OpenClawType.body)
|
||||
.toolbar {
|
||||
if let headerLeadingAction {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
self.agentFilterMenu
|
||||
self.gatewayToolbarButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var skillsDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.detailSummaryCard(
|
||||
icon: "sparkles",
|
||||
title: "Skills",
|
||||
value: self.skillsValue,
|
||||
detail: self.skillsDetail,
|
||||
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary)
|
||||
self.skillsPolicyControls
|
||||
self.skillsFilterField
|
||||
self.clawHubSearchCard
|
||||
self.skillsList
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Skills")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var instancesDestination: some View {
|
||||
AgentProNodesDestination(
|
||||
headerLeadingAction: self.directHeaderLeadingAction(for: .instances),
|
||||
overview: self.overview,
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
agentCount: self.appModel.gatewayAgents.count,
|
||||
instancesValue: self.instancesValue,
|
||||
instancesDetail: self.instancesDetail,
|
||||
instancesColor: self.instancesColor,
|
||||
refresh: {
|
||||
await self.refreshOverview(force: true)
|
||||
})
|
||||
}
|
||||
|
||||
var cronDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.directHeader(
|
||||
for: .cron,
|
||||
title: "Cron Jobs",
|
||||
subtitle: self.cronDetail)
|
||||
self.detailSummaryCard(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "Cron Jobs",
|
||||
value: self.cronValue,
|
||||
detail: self.cronDetail,
|
||||
color: self.cronColor)
|
||||
self.cronStatusCard
|
||||
self.cronJobsList(limit: nil)
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Cron Jobs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var usageDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.directHeader(
|
||||
for: .usage,
|
||||
title: "Usage",
|
||||
subtitle: self.usageDetail)
|
||||
self.detailSummaryCard(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
title: "Usage",
|
||||
value: self.usageValue,
|
||||
detail: self.usageDetail,
|
||||
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary)
|
||||
self.usageTotalsCard
|
||||
self.usageDailyList
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Usage")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var dreamingDestination: some View {
|
||||
AgentProDreamingDestination(
|
||||
headerLeadingAction: self.directHeaderLeadingAction(for: .dreaming),
|
||||
overview: self.overview,
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
overviewLoading: self.overviewLoading,
|
||||
dreamingValue: self.dreamingValue,
|
||||
dreamingDetail: self.dreamingDetail,
|
||||
dreamingColor: self.dreamingColor,
|
||||
refresh: {
|
||||
await self.refreshOverview(force: true)
|
||||
})
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func directHeader(for route: AgentRoute, title: String, subtitle: String) -> some View {
|
||||
if let headerLeadingAction = self.directHeaderLeadingAction(for: route) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
titleFont: OpenClawType.title3SemiBold,
|
||||
subtitleFont: OpenClawType.subheadMedium)
|
||||
{
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
func directHeaderLeadingAction(for route: AgentRoute) -> OpenClawSidebarHeaderAction? {
|
||||
self.directRoute == route ? self.headerLeadingAction : nil
|
||||
}
|
||||
|
||||
func detailSummaryCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(OpenClawType.headline)
|
||||
Text(detail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: value, color: color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
36
apps/ios/Sources/Design/AgentProTab+DetailComponents.swift
Normal file
36
apps/ios/Sources/Design/AgentProTab+DetailComponents.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
func detailMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(label)
|
||||
.font(OpenClawType.caption2Medium)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(
|
||||
Color.primary.opacity(0.055),
|
||||
in: RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous))
|
||||
}
|
||||
|
||||
func emptyDetailRow(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(detail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
223
apps/ios/Sources/Design/AgentProTab+GatewayData.swift
Normal file
223
apps/ios/Sources/Design/AgentProTab+GatewayData.swift
Normal file
@@ -0,0 +1,223 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
func agentName(for agent: AgentSummary) -> String {
|
||||
self.normalized(agent.name) ?? agent.id
|
||||
}
|
||||
|
||||
func agentBadge(for agent: AgentSummary) -> String {
|
||||
if let identity = agent.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
let normalizedEmoji = self.normalized(emoji)
|
||||
{
|
||||
return normalizedEmoji
|
||||
}
|
||||
|
||||
let words = self.agentName(for: agent)
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||
.prefix(2)
|
||||
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||
return initials.isEmpty ? "OC" : initials.uppercased()
|
||||
}
|
||||
|
||||
func agentTint(for agent: AgentSummary, state: AgentRosterState) -> Color {
|
||||
if agent.id == self.activeAgentID { return OpenClawBrand.accent }
|
||||
return state.color.opacity(0.62)
|
||||
}
|
||||
|
||||
func agentDetail(for agent: AgentSummary) -> String {
|
||||
let parts = [
|
||||
self.modelLabel(for: agent),
|
||||
agent.id == self.appModel.gatewayDefaultAgentId ? "Default" : nil,
|
||||
].compactMap(\.self)
|
||||
return parts.isEmpty ? agent.id : parts.joined(separator: " • ")
|
||||
}
|
||||
|
||||
func agentAccessibilityLabel(
|
||||
_ agent: AgentSummary,
|
||||
isActive: Bool,
|
||||
state: AgentRosterState) -> String
|
||||
{
|
||||
let status = state == .online ? "Online" : "Ready"
|
||||
let selection = isActive ? "Selected" : "Not selected"
|
||||
return "\(self.agentName(for: agent)), \(self.agentDetail(for: agent)), \(status), \(selection)"
|
||||
}
|
||||
|
||||
func agentRosterState(for agent: AgentSummary) -> AgentRosterState {
|
||||
guard self.gatewayConnected else { return .ready }
|
||||
if agent.id == self.activeAgentID { return .online }
|
||||
return .ready
|
||||
}
|
||||
|
||||
func modelLabel(for agent: AgentSummary) -> String? {
|
||||
guard let model = agent.model else { return nil }
|
||||
for key in ["primary", "name", "id", "model"] {
|
||||
if let value = model[key]?.value as? String,
|
||||
let normalized = self.normalized(value)
|
||||
{
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func presenceLabel(_ entry: PresenceEntry) -> String? {
|
||||
self.normalized(entry.host)
|
||||
?? self.normalized(entry.devicefamily)
|
||||
?? self.normalized(entry.platform)
|
||||
?? self.normalized(entry.mode)
|
||||
}
|
||||
|
||||
func cronJobDetail(_ job: CronJob) -> String {
|
||||
if let nextRunAtMs = AgentProValueReader.intValue(job.state["nextRunAtMs"]) {
|
||||
return "Next \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
|
||||
}
|
||||
if let description = self.normalized(job.description) {
|
||||
return description
|
||||
}
|
||||
if let agentId = self.normalized(job.agentid) {
|
||||
return agentId
|
||||
}
|
||||
return job.id
|
||||
}
|
||||
|
||||
func cronJobState(_ job: CronJob) -> String {
|
||||
if !job.enabled {
|
||||
return "paused"
|
||||
}
|
||||
if let status = Self.stringValue(job.state["lastStatus"]) ?? Self.stringValue(job.state["lastRunStatus"]) {
|
||||
return status
|
||||
}
|
||||
return "enabled"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func refreshOverview(force: Bool) async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.liveGatewayConnected else {
|
||||
self.overview = nil
|
||||
self.overviewErrorText = nil
|
||||
self.overviewLoading = false
|
||||
return
|
||||
}
|
||||
if self.overviewLoading, force == false {
|
||||
return
|
||||
}
|
||||
|
||||
self.overviewLoading = true
|
||||
self.overviewErrorText = nil
|
||||
defer { self.overviewLoading = false }
|
||||
|
||||
let activeAgentID = self.activeAgentID
|
||||
let skillsParams = Self.agentScopedParams(agentId: activeAgentID)
|
||||
async let skills = self.requestOptional(
|
||||
SkillStatusReportLite.self,
|
||||
method: "skills.status",
|
||||
paramsJSON: skillsParams)
|
||||
async let config = self.requestOptional(ConfigSnapshotLite.self, method: "config.get")
|
||||
async let presence = self.requestOptional([PresenceEntry].self, method: "system-presence")
|
||||
async let cronStatus = self.requestOptional(CronStatusLite.self, method: "cron.status")
|
||||
async let cronJobs = self.requestOptional(
|
||||
CronJobsListLite.self,
|
||||
method: "cron.list",
|
||||
paramsJSON: "{\"includeDisabled\":true,\"limit\":8,\"sortBy\":\"nextRunAtMs\",\"sortDir\":\"asc\"}",
|
||||
timeoutSeconds: 12)
|
||||
async let dreaming = self.requestOptional(DreamingStatusEnvelope.self, method: "doctor.memory.status")
|
||||
async let dreamDiary = self.requestOptional(DreamDiaryLite.self, method: "doctor.memory.dreamDiary")
|
||||
async let usage = self.requestOptional(
|
||||
CostUsageSummaryLite.self,
|
||||
method: "usage.cost",
|
||||
paramsJSON: "{\"days\":31}",
|
||||
timeoutSeconds: 12)
|
||||
|
||||
let loadedSkills = await skills
|
||||
let loadedConfig = await config
|
||||
let loadedPresence = await presence
|
||||
let loadedCronStatus = await cronStatus
|
||||
let loadedCronJobs = await cronJobs
|
||||
let loadedDreaming = await dreaming
|
||||
let loadedDreamDiary = await dreamDiary
|
||||
let loadedUsage = await usage
|
||||
let snapshot = AgentOverviewSnapshot(
|
||||
skills: loadedSkills,
|
||||
presence: loadedPresence ?? [],
|
||||
cronStatus: loadedCronStatus,
|
||||
cronJobs: loadedCronJobs?.jobs ?? [],
|
||||
dreaming: loadedDreaming?.dreaming,
|
||||
dreamDiary: loadedDreamDiary,
|
||||
usage: loadedUsage,
|
||||
activeAgentId: activeAgentID,
|
||||
agentSkillFilter: loadedSkills?.agentSkillFilter
|
||||
?? loadedConfig?.effectiveSkillFilter(agentId: activeAgentID),
|
||||
loadedAt: Date())
|
||||
|
||||
if snapshot.hasAnyLiveData {
|
||||
self.overview = snapshot
|
||||
} else {
|
||||
self.overview = snapshot
|
||||
self.overviewErrorText = "Live overview could not load yet."
|
||||
}
|
||||
}
|
||||
|
||||
func requestOptional<T: Decodable>(
|
||||
_ type: T.Type,
|
||||
method: String,
|
||||
paramsJSON: String = "{}",
|
||||
timeoutSeconds: Int = 8) async -> T?
|
||||
{
|
||||
do {
|
||||
let data = try await self.appModel.operatorSession.request(
|
||||
method: method,
|
||||
paramsJSON: paramsJSON,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalized(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func stringValue(_ value: AnyCodable?) -> String? {
|
||||
guard let string = value?.value as? String else { return nil }
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
|
||||
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
|
||||
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
|
||||
}
|
||||
|
||||
static func compactNumber(_ value: Int) -> String {
|
||||
value.formatted(.number.notation(.compactName))
|
||||
}
|
||||
|
||||
static func currency(_ value: Double) -> String {
|
||||
value.formatted(.currency(code: "USD").precision(.fractionLength(0...2)))
|
||||
}
|
||||
|
||||
static func duration(milliseconds: Int) -> String {
|
||||
let seconds = max(0, milliseconds / 1000)
|
||||
if seconds < 60 { return "\(seconds)s" }
|
||||
let minutes = seconds / 60
|
||||
if minutes < 60 { return "\(minutes)m" }
|
||||
let hours = minutes / 60
|
||||
if hours < 24 { return "\(hours)h" }
|
||||
return "\(hours / 24)d"
|
||||
}
|
||||
|
||||
static func agentScopedParams(agentId: String) -> String {
|
||||
guard let data = try? JSONEncoder().encode(["agentId": agentId]),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return "{}"
|
||||
}
|
||||
return json
|
||||
}
|
||||
}
|
||||
690
apps/ios/Sources/Design/AgentProTab+Overview.swift
Normal file
690
apps/ios/Sources/Design/AgentProTab+Overview.swift
Normal file
@@ -0,0 +1,690 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
var rosterHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.headerTitle,
|
||||
subtitle: "\(self.sortedAgents.count) total",
|
||||
titleFont: OpenClawType.title2SemiBold,
|
||||
subtitleFont: OpenClawType.subheadMedium,
|
||||
subtitleLineLimit: 1)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
} accessory: {
|
||||
OpenClawGlassControlGroup {
|
||||
HStack(spacing: 10) {
|
||||
self.gatewayPillButton
|
||||
self.headerIconButton(
|
||||
systemName: "magnifyingglass",
|
||||
label: "Search agents",
|
||||
action: {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
self.agentSearchPresented.toggle()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
if self.agentSearchPresented {
|
||||
TextField("Search agents", text: self.$agentSearchText)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(OpenClawType.subhead)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(height: 38)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPillButton: some View {
|
||||
if let openSettings {
|
||||
Button(action: openSettings) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonBorderShape(.capsule)
|
||||
.openClawGlassButton()
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
|
||||
var agentFilters: some View {
|
||||
HStack(spacing: 10) {
|
||||
Picker("Agent status", selection: self.$agentRosterFilter) {
|
||||
ForEach(AgentRosterFilter.allCases) { filter in
|
||||
Text(filter.title)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.tag(filter)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
if self.agentFiltersActive {
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
self.agentRosterFilter = .all
|
||||
self.agentSearchText = ""
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(OpenClawType.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Clear filters")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var agentFilterMenu: some View {
|
||||
Menu {
|
||||
Picker("Agent status", selection: self.$agentRosterFilter) {
|
||||
ForEach(AgentRosterFilter.allCases) { filter in
|
||||
Label(filter.title, systemImage: filter.systemImage)
|
||||
.font(OpenClawType.subhead)
|
||||
.tag(filter)
|
||||
}
|
||||
}
|
||||
if self.agentFiltersActive {
|
||||
Divider()
|
||||
Button {
|
||||
self.agentRosterFilter = .all
|
||||
self.agentSearchText = ""
|
||||
} label: {
|
||||
Label("Clear Filters", systemImage: "xmark.circle")
|
||||
.font(OpenClawType.subhead)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Filter agents", systemImage: "line.3.horizontal.decrease")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.accessibilityIdentifier("agent-status-filter-menu")
|
||||
.accessibilityValue(agentRosterFilter.title)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var gatewayToolbarButton: some View {
|
||||
if let openSettings {
|
||||
Button(action: openSettings) {
|
||||
Image(systemName: self.gatewayConnected ? "antenna.radiowaves.left.and.right" : "wifi.slash")
|
||||
}
|
||||
.tint(self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
.accessibilityLabel(self.gatewayConnected ? "Gateway online" : "Gateway offline")
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
}
|
||||
}
|
||||
|
||||
var agentFiltersActive: Bool {
|
||||
agentRosterFilter != .all
|
||||
|| !agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var agentsSection: some View {
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
if self.filteredAgents.isEmpty {
|
||||
self.emptyAgentsRow
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(self.filteredAgents.enumerated()), id: \.element.id) { index, agent in
|
||||
self.agentRow(agent)
|
||||
if index < self.filteredAgents.count - 1 {
|
||||
Divider().padding(.leading, 76)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var operationsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Live Operations")
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||
self.metricTile(
|
||||
icon: "sparkles",
|
||||
title: "Skills",
|
||||
value: self.skillsValue,
|
||||
detail: self.skillsDetail,
|
||||
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary,
|
||||
route: .skills)
|
||||
self.metricTile(
|
||||
icon: "externaldrive.connected.to.line.below",
|
||||
title: "Instances",
|
||||
value: self.instancesValue,
|
||||
detail: self.instancesDetail,
|
||||
color: self.instancesColor,
|
||||
route: .instances)
|
||||
self.metricTile(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "Cron",
|
||||
value: self.cronValue,
|
||||
detail: self.cronDetail,
|
||||
color: self.cronColor,
|
||||
route: .cron)
|
||||
self.metricTile(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
title: "Usage",
|
||||
value: self.usageValue,
|
||||
detail: self.usageDetail,
|
||||
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary,
|
||||
route: .usage)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
if let overviewErrorText {
|
||||
Text(overviewErrorText)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dreamingSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Dreaming")
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
NavigationLink(value: AgentRoute.dreaming) {
|
||||
self.agentMenuRow(
|
||||
icon: "moon",
|
||||
title: "Dreaming",
|
||||
detail: self.dreamingDetail,
|
||||
value: self.dreamingValue,
|
||||
color: self.dreamingColor,
|
||||
showsChevron: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var cronSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Scheduled Work")
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
let jobs = self.recentCronJobs
|
||||
if jobs.isEmpty {
|
||||
NavigationLink(value: AgentRoute.cron) {
|
||||
self.emptyCronRow
|
||||
.padding(14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(jobs.enumerated()), id: \.element.id) { index, job in
|
||||
NavigationLink(value: AgentRoute.cron) {
|
||||
self.cronJobRow(job)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if index < jobs.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var emptyAgentsRow: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "person.2.slash", color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.emptyAgentsTitle)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(self.emptyAgentsDetail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
func agentRow(_ agent: AgentSummary) -> some View {
|
||||
let isActive = agent.id == self.activeAgentID
|
||||
let state = agentRosterState(for: agent)
|
||||
return Button {
|
||||
guard !isActive else { return }
|
||||
self.appModel.setSelectedAgentId(agent.id)
|
||||
} label: {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
self.agentAvatar(agent, state: state)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.agentName(for: agent))
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(self.agentDetail(for: agent))
|
||||
.font(OpenClawType.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if isActive {
|
||||
Image(systemName: "checkmark")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.frame(width: 24, height: 44)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(agentAccessibilityLabel(agent, isActive: isActive, state: state))
|
||||
.accessibilityHint(isActive ? "Selected agent" : "Selects this agent")
|
||||
}
|
||||
|
||||
func headerIconButton(
|
||||
systemName: String,
|
||||
label: String,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.frame(width: AgentLayout.filterHeight, height: AgentLayout.filterHeight)
|
||||
}
|
||||
.buttonBorderShape(.circle)
|
||||
.openClawGlassButton()
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
|
||||
func agentAvatar(_ agent: AgentSummary, state: AgentRosterState) -> some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Text(self.agentBadge(for: agent))
|
||||
.font(OpenClawType.avatar(size: self.agentBadge(for: agent).count > 2 ? 14 : 18))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.62)
|
||||
.lineLimit(1)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(self.agentTint(for: agent, state: state).gradient))
|
||||
.overlay(Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
|
||||
|
||||
Circle()
|
||||
.fill(state.color)
|
||||
.frame(width: 8, height: 8)
|
||||
.overlay(Circle().strokeBorder(Color(uiColor: .systemBackground), lineWidth: 2))
|
||||
}
|
||||
}
|
||||
|
||||
func agentMenuRow(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color,
|
||||
showsChevron: Bool = false) -> some View
|
||||
{
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(detail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(OpenClawType.caption2SemiBold)
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
if showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
func metricTile(
|
||||
icon: String,
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String,
|
||||
color: Color,
|
||||
route: AgentRoute? = nil) -> some View
|
||||
{
|
||||
Group {
|
||||
if let route {
|
||||
NavigationLink(value: route) {
|
||||
self.metricTileContent(
|
||||
icon: icon,
|
||||
title: title,
|
||||
value: value,
|
||||
detail: detail,
|
||||
color: color,
|
||||
showsChevron: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
self.metricTileContent(
|
||||
icon: icon,
|
||||
title: title,
|
||||
value: value,
|
||||
detail: detail,
|
||||
color: color,
|
||||
showsChevron: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metricTileContent(
|
||||
icon: String,
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String,
|
||||
color: Color,
|
||||
showsChevron: Bool) -> some View
|
||||
{
|
||||
ProCard(padding: 12, radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
Spacer()
|
||||
ProValuePill(value: value, color: color)
|
||||
if showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
Text(detail)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: AgentLayout.metricTileHeight, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
var emptyCronRow: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "clock.badge.questionmark", color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.gatewayConnected ? "No scheduled jobs" : "Cron unavailable")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(self.gatewayConnected
|
||||
? "The gateway has no visible cron jobs."
|
||||
: "Connect a gateway to load scheduled work.")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
func cronJobRow(_ job: CronJob) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: job.enabled ? "clock.arrow.circlepath" : "pause.circle",
|
||||
color: job.enabled ? OpenClawBrand.accent : .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(job.name)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
Text(self.cronJobDetail(job))
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.cronJobState(job))
|
||||
.font(OpenClawType.caption2SemiBold)
|
||||
.foregroundStyle(job.enabled ? OpenClawBrand.accent : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
var sortedAgents: [AgentSummary] {
|
||||
appModel.gatewayAgents.sorted { lhs, rhs in
|
||||
if lhs.id == self.activeAgentID { return true }
|
||||
if rhs.id == self.activeAgentID { return false }
|
||||
return self.agentName(for: lhs)
|
||||
.localizedCaseInsensitiveCompare(self.agentName(for: rhs)) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
var filteredAgents: [AgentSummary] {
|
||||
let query = agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return self.sortedAgents.filter { agent in
|
||||
let matchesFilter: Bool = switch self.agentRosterFilter {
|
||||
case .all:
|
||||
true
|
||||
case .online:
|
||||
self.agentRosterState(for: agent) == .online
|
||||
case .ready:
|
||||
self.agentRosterState(for: agent) == .ready
|
||||
}
|
||||
|
||||
guard matchesFilter else { return false }
|
||||
guard !query.isEmpty else { return true }
|
||||
let haystack = [
|
||||
self.agentName(for: agent),
|
||||
agent.id,
|
||||
self.normalized(agent.workspace),
|
||||
self.modelLabel(for: agent),
|
||||
]
|
||||
.compactMap(\.self)
|
||||
.joined(separator: " ")
|
||||
return haystack.localizedCaseInsensitiveContains(query)
|
||||
}
|
||||
}
|
||||
|
||||
var activeAgentID: String {
|
||||
normalized(appModel.selectedAgentId)
|
||||
?? normalized(appModel.gatewayDefaultAgentId)
|
||||
?? "main"
|
||||
}
|
||||
|
||||
var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: appModel) == .connected
|
||||
}
|
||||
|
||||
var liveGatewayConnected: Bool {
|
||||
!appModel.isLocalGatewayFixtureEnabled &&
|
||||
self.gatewayConnected &&
|
||||
appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
var emptyAgentsTitle: String {
|
||||
if !self.gatewayConnected { return "Agents unavailable" }
|
||||
if !agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matches" }
|
||||
if agentRosterFilter != .all { return "No \(agentRosterFilter.title.lowercased()) agents" }
|
||||
return "No agents reported"
|
||||
}
|
||||
|
||||
var emptyAgentsDetail: String {
|
||||
if !self.gatewayConnected { return "Connect a gateway to load the live agent roster." }
|
||||
if !agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "Try another search or clear the agent filters."
|
||||
}
|
||||
if agentRosterFilter != .all { return "Clear the filter to view the full roster." }
|
||||
return "The connected gateway did not return an agent list."
|
||||
}
|
||||
|
||||
var overviewTaskID: String {
|
||||
[
|
||||
self.gatewayConnected ? "connected" : "offline",
|
||||
appModel.isOperatorGatewayConnected ? "operator" : "no-operator",
|
||||
self.activeAgentID,
|
||||
scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
var skillsValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let skills = overview?.skills else {
|
||||
return overviewLoading ? "..." : "live"
|
||||
}
|
||||
return "\(skills.enabledCount)/\(skills.totalCount)"
|
||||
}
|
||||
|
||||
var skillsDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load skills." }
|
||||
guard let skills = overview?.skills else {
|
||||
return overviewLoading ? "Loading skill status." : "Skill status is available from the gateway."
|
||||
}
|
||||
if skills.blockedCount > 0 {
|
||||
return "\(skills.enabledCount) enabled, \(skills.blockedCount) blocked"
|
||||
}
|
||||
if skills.missingRequirementCount > 0 {
|
||||
return "\(skills.enabledCount) enabled, \(skills.missingRequirementCount) need setup"
|
||||
}
|
||||
return "\(skills.enabledCount) enabled, \(skills.totalCount) installed"
|
||||
}
|
||||
|
||||
var instancesValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let count = overview?.presence.count else {
|
||||
return overviewLoading ? "..." : "live"
|
||||
}
|
||||
return "\(count)"
|
||||
}
|
||||
|
||||
var instancesDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load instances." }
|
||||
guard let presence = overview?.presence else {
|
||||
return overviewLoading ? "Loading instance presence." : "Instance presence is available."
|
||||
}
|
||||
let labels = presence.prefix(2).compactMap(presenceLabel)
|
||||
if labels.isEmpty {
|
||||
return "No live instances reported."
|
||||
}
|
||||
return labels.joined(separator: ", ")
|
||||
}
|
||||
|
||||
var instancesColor: Color {
|
||||
guard self.gatewayConnected else { return .secondary }
|
||||
return (overview?.presence.isEmpty == false) ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var cronValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let cronStatus = overview?.cronStatus else {
|
||||
return overviewLoading ? "..." : "live"
|
||||
}
|
||||
return cronStatus.enabled ? "\(cronStatus.jobs)" : "off"
|
||||
}
|
||||
|
||||
var cronDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load cron." }
|
||||
guard let cronStatus = overview?.cronStatus else {
|
||||
return overviewLoading ? "Loading cron status." : "Cron status is available."
|
||||
}
|
||||
if let nextWakeAtMs = cronStatus.nextwakeatms {
|
||||
return "Next wake \(Self.relativeTime(fromMilliseconds: nextWakeAtMs))"
|
||||
}
|
||||
return cronStatus.enabled ? "Scheduler enabled" : "Scheduler disabled"
|
||||
}
|
||||
|
||||
var cronColor: Color {
|
||||
guard self.gatewayConnected else { return .secondary }
|
||||
return overview?.cronStatus?.enabled == true ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var usageValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let usage = overview?.usage else {
|
||||
return overviewLoading ? "..." : "7d"
|
||||
}
|
||||
if let cost = usage.totalCost {
|
||||
return Self.currency(cost)
|
||||
}
|
||||
if let tokens = usage.totalTokens, tokens > 0 {
|
||||
return Self.compactNumber(tokens)
|
||||
}
|
||||
return "7d"
|
||||
}
|
||||
|
||||
var usageDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load usage." }
|
||||
guard let usage = overview?.usage else {
|
||||
return overviewLoading ? "Loading recent usage." : "Recent usage is available."
|
||||
}
|
||||
if let tokens = usage.totalTokens, tokens > 0 {
|
||||
return "\(Self.compactNumber(tokens)) tokens in \(usage.days ?? 7)d"
|
||||
}
|
||||
return "No token usage reported for \(usage.days ?? 7)d."
|
||||
}
|
||||
|
||||
var dreamingValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let dreaming = overview?.dreaming else {
|
||||
return overviewLoading ? "..." : "live"
|
||||
}
|
||||
return dreaming.enabled ? "on" : "off"
|
||||
}
|
||||
|
||||
var dreamingDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load dreaming." }
|
||||
guard let dreaming = overview?.dreaming else {
|
||||
return overviewLoading ? "Loading dreaming status." : "Background memory status is available."
|
||||
}
|
||||
if let nextRunAtMs = dreaming.nextRunAtMs {
|
||||
return "Next cycle \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
|
||||
}
|
||||
return "\(dreaming.totalSignalCount ?? 0) signals, \(dreaming.promotedToday ?? 0) promoted today"
|
||||
}
|
||||
|
||||
var dreamingColor: Color {
|
||||
guard self.gatewayConnected else { return .secondary }
|
||||
return overview?.dreaming?.enabled == true ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var recentCronJobs: [CronJob] {
|
||||
(overview?.cronJobs ?? [])
|
||||
.sorted { lhs, rhs in
|
||||
let lhsNext = AgentProValueReader.intValue(lhs.state["nextRunAtMs"])
|
||||
let rhsNext = AgentProValueReader.intValue(rhs.state["nextRunAtMs"])
|
||||
switch (lhsNext, rhsNext) {
|
||||
case let (lhsNext?, rhsNext?): return lhsNext < rhsNext
|
||||
case (_?, nil): return true
|
||||
case (nil, _?): return false
|
||||
case (nil, nil): return lhs.updatedatms > rhs.updatedatms
|
||||
}
|
||||
}
|
||||
.prefix(4)
|
||||
.map(\.self)
|
||||
}
|
||||
}
|
||||
848
apps/ios/Sources/Design/AgentProTab+Skills.swift
Normal file
848
apps/ios/Sources/Design/AgentProTab+Skills.swift
Normal file
@@ -0,0 +1,848 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
var skillsPolicyControls: some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.activeAgentName)
|
||||
.font(OpenClawType.headline)
|
||||
Text(self.skillPolicySummary)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(
|
||||
value: self.agentSkillFilter == nil ? "all" : "\(self.agentSkillFilter?.count ?? 0)",
|
||||
color: OpenClawBrand.accent)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await self.enableAllSkills() }
|
||||
} label: {
|
||||
Text("Enable All")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.disabled(self.skillMutationBusy)
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task { await self.disableAllSkills() }
|
||||
} label: {
|
||||
Text("Disable All")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.disabled(self.skillMutationBusy)
|
||||
|
||||
Button {
|
||||
Task { await self.resetSkillPolicy() }
|
||||
} label: {
|
||||
Text("Reset")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.disabled(self.skillMutationBusy || self.agentSkillFilter == nil)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
|
||||
if let skillMutationStatusText {
|
||||
Text(skillMutationStatusText)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
}
|
||||
if let skillMutationErrorText {
|
||||
Text(skillMutationErrorText)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var skillsFilterField: some View {
|
||||
ProCard(padding: 10, radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search skills", text: self.$skillFilter)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(OpenClawType.subhead)
|
||||
if !self.skillFilter.isEmpty {
|
||||
Button {
|
||||
self.skillFilter = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
Picker("Status", selection: self.$skillStatusFilter) {
|
||||
ForEach(SkillStatusFilter.allCases) { filter in
|
||||
Text(filter.title)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.tag(filter)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var clawHubSearchCard: some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
ProIconBadge(systemName: "square.and.arrow.down", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Install Skills")
|
||||
.font(OpenClawType.headline)
|
||||
Text("Search ClawHub and install into this workspace.")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
Task { await self.searchClawHubSkills() }
|
||||
} label: {
|
||||
Image(systemName: "magnifyingglass")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(self.clawHubLoading || !self.liveGatewayConnected)
|
||||
.accessibilityLabel("Search ClawHub")
|
||||
}
|
||||
|
||||
TextField("Search ClawHub", text: self.$clawHubQuery)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(OpenClawType.subhead)
|
||||
.submitLabel(.search)
|
||||
.onSubmit {
|
||||
Task { await self.searchClawHubSkills() }
|
||||
}
|
||||
|
||||
if self.clawHubLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let clawHubErrorText {
|
||||
Text(clawHubErrorText)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
if !self.clawHubResults.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
let results = Array(self.clawHubResults.prefix(8))
|
||||
ForEach(Array(results.enumerated()), id: \.element.slug) { index, result in
|
||||
self.clawHubResultRow(result)
|
||||
if index < results.count - 1 {
|
||||
Divider().padding(.leading, 42)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func clawHubResultRow(_ result: ClawHubSearchResultLite) -> some View {
|
||||
let installing = clawHubInstallSlug == result.slug
|
||||
return HStack(alignment: .top, spacing: 10) {
|
||||
ProIconBadge(systemName: "sparkles", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(result.displayName)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
Text(result.summary ?? result.slug)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
Task { await self.installClawHubSkill(result) }
|
||||
} label: {
|
||||
Image(systemName: installing ? "hourglass" : "square.and.arrow.down")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(installing || !self.skillConfigBusyKeys.isEmpty)
|
||||
.accessibilityLabel("Install \(result.displayName)")
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
var skillsList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Installed Skills")
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
let skills = self.filteredSkills
|
||||
if skills.isEmpty {
|
||||
self.emptyDetailRow(
|
||||
icon: "sparkles",
|
||||
title: self.gatewayConnected ? "No skills found" : "Skills unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "Try a different search or refresh from the gateway."
|
||||
: "Connect a gateway to load workspace skills.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(skills.enumerated()), id: \.element.name) { index, skill in
|
||||
self.skillRow(skill)
|
||||
if index < skills.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var activeAgentName: String {
|
||||
if let agent = appModel.gatewayAgents.first(where: { $0.id == self.activeAgentID }) {
|
||||
return agentName(for: agent)
|
||||
}
|
||||
return activeAgentID
|
||||
}
|
||||
|
||||
var agentSkillFilter: Set<String>? {
|
||||
overview?.agentSkillFilter.map { Set($0) }
|
||||
}
|
||||
|
||||
var skillPolicySummary: String {
|
||||
if appModel.isAppleReviewDemoModeEnabled { return "Demo mode keeps live skill changes disabled." }
|
||||
guard gatewayConnected else { return "Connect a gateway to edit skills." }
|
||||
guard let filter = agentSkillFilter else {
|
||||
return "All available skills are allowed for this agent."
|
||||
}
|
||||
if filter.isEmpty {
|
||||
return "No skills are allowed for this agent."
|
||||
}
|
||||
return "\(filter.count) skills are allowed for this agent."
|
||||
}
|
||||
|
||||
var skillMutationBusy: Bool {
|
||||
!skillMutationBusyKeys.isEmpty
|
||||
}
|
||||
|
||||
var filteredSkills: [SkillStatusEntryLite] {
|
||||
let skills = overview?.skills?.skills ?? []
|
||||
let filter = skillFilter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return skills
|
||||
.filter { skill in
|
||||
self.matchesSkillStatusFilter(skill)
|
||||
}
|
||||
.filter { skill in
|
||||
guard !filter.isEmpty else { return true }
|
||||
return [
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.source,
|
||||
].compactMap(\.self)
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
.contains(filter)
|
||||
}
|
||||
.sorted(by: self.sortSkills)
|
||||
}
|
||||
|
||||
func matchesSkillStatusFilter(_ skill: SkillStatusEntryLite) -> Bool {
|
||||
switch skillStatusFilter {
|
||||
case .all:
|
||||
true
|
||||
case .enabled:
|
||||
self.skillStatus(skill).text == "enabled"
|
||||
case .off:
|
||||
!self.isSkillAllowed(skill) || skill.blockedByAgentFilter == true
|
||||
case .setup:
|
||||
skill.hasMissingRequirements
|
||||
case .blocked:
|
||||
skill.blockedByAllowlist == true
|
||||
}
|
||||
}
|
||||
|
||||
func sortSkills(_ lhs: SkillStatusEntryLite, _ rhs: SkillStatusEntryLite) -> Bool {
|
||||
let lhsEnabled = self.isSkillAllowed(lhs)
|
||||
let rhsEnabled = self.isSkillAllowed(rhs)
|
||||
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
|
||||
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
|
||||
func skillRow(_ skill: SkillStatusEntryLite) -> some View {
|
||||
let status = self.skillStatus(skill)
|
||||
let busy = skillMutationBusyKeys.contains(skill.name)
|
||||
return HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.isSkillAllowed(skill) ? "checkmark.circle" : "nosign", color: status.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(skill.displayName)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
Text(self.normalized(skill.description) ?? self.normalized(skill.source) ?? "Workspace skill")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let missing = skill.missingSummary {
|
||||
Text("Missing: \(missing)")
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let install = skill.installSummary {
|
||||
Text("Setup: \(install)")
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
self.skillToggle(skill, title: status.text)
|
||||
HStack(spacing: 6) {
|
||||
if self.canInstallSkillRequirements(skill) {
|
||||
Button {
|
||||
Task { await self.installSkillRequirements(skill) }
|
||||
} label: {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
.disabled(self.isSkillConfigBusy(skill))
|
||||
.accessibilityLabel("Set up \(skill.displayName)")
|
||||
}
|
||||
Button {
|
||||
self.openSkillEditor(skill)
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
.accessibilityLabel("Edit \(skill.displayName)")
|
||||
}
|
||||
Text(busy ? "saving" : status.text)
|
||||
.font(OpenClawType.caption2SemiBold)
|
||||
.foregroundStyle(status.color)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
func skillToggle(_ skill: SkillStatusEntryLite, title: String) -> some View {
|
||||
Toggle(
|
||||
title,
|
||||
isOn: Binding(
|
||||
get: { self.isSkillAllowed(skill) },
|
||||
set: { enabled in
|
||||
Task { await self.setSkillAllowed(skill, enabled: enabled) }
|
||||
}))
|
||||
.labelsHidden()
|
||||
.disabled(self.skillMutationBusy)
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
|
||||
func isSkillAllowed(_ skill: SkillStatusEntryLite) -> Bool {
|
||||
guard let filter = agentSkillFilter else { return true }
|
||||
return filter.contains(skill.name)
|
||||
}
|
||||
|
||||
func isSkillConfigBusy(_ skill: SkillStatusEntryLite) -> Bool {
|
||||
skillConfigBusyKeys.contains(skill.effectiveSkillKey)
|
||||
|| clawHubInstallSlug != nil
|
||||
}
|
||||
|
||||
func canInstallSkillRequirements(_ skill: SkillStatusEntryLite) -> Bool {
|
||||
skill.install?.first?.id?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
&& !skill.missingBins.isEmpty
|
||||
}
|
||||
|
||||
func skillByKey(_ key: String) -> SkillStatusEntryLite? {
|
||||
(overview?.skills?.skills ?? []).first { skill in
|
||||
skill.effectiveSkillKey == key || skill.name == key
|
||||
}
|
||||
}
|
||||
|
||||
func openSkillEditor(_ skill: SkillStatusEntryLite) {
|
||||
skillEditorSelection = SkillEditorSelection(id: skill.effectiveSkillKey)
|
||||
}
|
||||
|
||||
func skillAPIKeyBinding(for skill: SkillStatusEntryLite) -> Binding<String> {
|
||||
Binding(
|
||||
get: { self.skillAPIKeyDrafts[skill.effectiveSkillKey] ?? "" },
|
||||
set: { self.skillAPIKeyDrafts[skill.effectiveSkillKey] = $0 })
|
||||
}
|
||||
|
||||
var missingSkillEditorSheet: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
VStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "sparkles", color: .secondary)
|
||||
Text("Skill unavailable")
|
||||
.font(OpenClawType.headline)
|
||||
Text("Return to the skills list and choose another skill.")
|
||||
.font(OpenClawType.subhead)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(OpenClawSpacing.space6)
|
||||
}
|
||||
.navigationTitle("Skill")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button {
|
||||
self.skillEditorSelection = nil
|
||||
} label: {
|
||||
Text("Close")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillEditorSheet(_ skill: SkillStatusEntryLite) -> some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.skillEditorHeader(skill)
|
||||
self.skillEditorControls(skill)
|
||||
self.skillEditorSetup(skill)
|
||||
self.skillEditorMetadata(skill)
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.navigationTitle(skill.displayName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button {
|
||||
self.skillEditorSelection = nil
|
||||
} label: {
|
||||
Text("Close")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillEditorHeader(_ skill: SkillStatusEntryLite) -> some View {
|
||||
let status = self.skillStatus(skill)
|
||||
return ProCard(radius: AgentLayout.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: skill.isGloballyEnabled ? "checkmark.circle" : "pause.circle",
|
||||
color: status.color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(skill.displayName)
|
||||
.font(OpenClawType.headline)
|
||||
Text(self.normalized(skill.description) ?? self.normalized(skill.source) ?? "Workspace skill")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: status.text, color: status.color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func skillEditorControls(_ skill: SkillStatusEntryLite) -> some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.skillEditorToggleRow(
|
||||
"Enabled globally",
|
||||
isOn: skill.isGloballyEnabled,
|
||||
disabled: self.isSkillConfigBusy(skill))
|
||||
{ enabled in
|
||||
Task { await self.updateSkillGlobalEnabled(skill, enabled: enabled) }
|
||||
}
|
||||
|
||||
if let primaryEnv = skill.primaryEnv, !primaryEnv.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("API key")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
self.skillSecureField(primaryEnv, text: self.skillAPIKeyBinding(for: skill))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Button {
|
||||
Task { await self.saveSkillAPIKey(skill) }
|
||||
} label: {
|
||||
Label("Save key", systemImage: "key")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(self.isSkillConfigBusy(skill))
|
||||
if let homepage = skill.homepageURL {
|
||||
Link("Get key", destination: homepage)
|
||||
.font(OpenClawType.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let message = self.skillConfigMessages[skill.effectiveSkillKey] {
|
||||
Text(message.text)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(message.kind == .success ? OpenClawBrand.accent : OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func skillSecureField(_ placeholder: String, text: Binding<String>) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
SecureField("", text: text)
|
||||
.font(OpenClawType.subhead)
|
||||
.accessibilityLabel(placeholder)
|
||||
if text.wrappedValue.isEmpty {
|
||||
Text(placeholder)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(.tertiary)
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.font(OpenClawType.subhead)
|
||||
}
|
||||
|
||||
func skillEditorToggleRow(
|
||||
_ title: String,
|
||||
isOn: Bool,
|
||||
disabled: Bool,
|
||||
onToggle: @escaping (Bool) -> Void) -> some View
|
||||
{
|
||||
// Native Toggle rows in this sheet can ignore visible-row taps on iOS 26.
|
||||
// Keep the switch semantics explicit so the control always dispatches the mutation.
|
||||
Button {
|
||||
onToggle(!isOn)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(OpenClawType.subhead)
|
||||
Spacer(minLength: 8)
|
||||
OpenClawToggleIndicator(isOn: isOn)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(disabled)
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityValue(isOn ? "On" : "Off")
|
||||
}
|
||||
|
||||
func skillEditorSetup(_ skill: SkillStatusEntryLite) -> some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Setup")
|
||||
.font(OpenClawType.headline)
|
||||
if let missing = skill.missingSummary {
|
||||
Text("Missing: \(missing)")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
} else {
|
||||
Text("No missing requirements reported.")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let install = skill.install?.first {
|
||||
Button {
|
||||
Task { await self.installSkillRequirements(skill) }
|
||||
} label: {
|
||||
Label(install.label, systemImage: "wrench.and.screwdriver")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(self.isSkillConfigBusy(skill) || install.id == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func skillEditorMetadata(_ skill: SkillStatusEntryLite) -> some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
self.detailMetric(label: "Key", value: skill.effectiveSkillKey)
|
||||
self.detailMetric(label: "Source", value: self.normalized(skill.source) ?? "unknown")
|
||||
if let filePath = self.normalized(skill.filePath) {
|
||||
Text(filePath)
|
||||
.font(OpenClawType.monoCaption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func setSkillAllowed(_ skill: SkillStatusEntryLite, enabled: Bool) async {
|
||||
let allNames = self.allSkillNames
|
||||
guard !allNames.isEmpty else { return }
|
||||
let base = self.agentSkillFilter ?? Set(allNames)
|
||||
var next = base
|
||||
if enabled {
|
||||
next.insert(skill.name)
|
||||
} else {
|
||||
next.remove(skill.name)
|
||||
}
|
||||
await self.patchAgentSkills(Array(next).sorted(), busyKey: skill.name)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func enableAllSkills() async {
|
||||
let allNames = self.allSkillNames
|
||||
guard !allNames.isEmpty else { return }
|
||||
await self.patchAgentSkills(allNames, busyKey: "__all__")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func disableAllSkills() async {
|
||||
await self.patchAgentSkills([], busyKey: "__all__")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resetSkillPolicy() async {
|
||||
await self.patchAgentSkills(nil, busyKey: "__all__")
|
||||
}
|
||||
|
||||
var allSkillNames: [String] {
|
||||
(overview?.skills?.skills ?? [])
|
||||
.map(\.name)
|
||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func patchAgentSkills(_ skills: [String]?, busyKey: String) async {
|
||||
guard liveGatewayConnected else { return }
|
||||
skillMutationBusyKeys.insert(busyKey)
|
||||
skillMutationErrorText = nil
|
||||
skillMutationStatusText = nil
|
||||
defer { self.skillMutationBusyKeys.remove(busyKey) }
|
||||
|
||||
do {
|
||||
let config = try await requestConfigSnapshot()
|
||||
guard let baseHash = normalized(config.hash) else {
|
||||
throw SkillMutationError.missingConfigHash
|
||||
}
|
||||
if skills == nil,
|
||||
config.agentConfig(id: activeAgentID) == nil
|
||||
{
|
||||
skillMutationStatusText = "This agent already inherits the default skill policy."
|
||||
return
|
||||
}
|
||||
|
||||
let raw = try Self.agentSkillsPatchRaw(agentId: activeAgentID, skills: skills)
|
||||
let params = ConfigPatchParams(
|
||||
raw: raw,
|
||||
baseHash: baseHash,
|
||||
replacePaths: ["agents.list[].skills"])
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
}
|
||||
_ = try await appModel.operatorSession.request(
|
||||
method: "config.patch",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: 20)
|
||||
skillMutationStatusText = skills == nil ? "Skill policy reset." : "Skill policy saved."
|
||||
await appModel.refreshGatewayOverviewIfConnected()
|
||||
await refreshOverview(force: true)
|
||||
} catch {
|
||||
skillMutationErrorText = Self.skillMutationMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updateSkillGlobalEnabled(_ skill: SkillStatusEntryLite, enabled: Bool) async {
|
||||
await self.runSkillConfigMutation(skill) {
|
||||
let params = SkillUpdateParams(skillKey: skill.effectiveSkillKey, enabled: enabled)
|
||||
_ = try await self.requestGateway(method: "skills.update", params: params, timeoutSeconds: 20)
|
||||
return enabled ? "Skill enabled." : "Skill disabled."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func saveSkillAPIKey(_ skill: SkillStatusEntryLite) async {
|
||||
await self.runSkillConfigMutation(skill) {
|
||||
let apiKey = self.skillAPIKeyDrafts[skill.effectiveSkillKey] ?? ""
|
||||
let params = SkillUpdateParams(skillKey: skill.effectiveSkillKey, apiKey: apiKey)
|
||||
_ = try await self.requestGateway(method: "skills.update", params: params, timeoutSeconds: 20)
|
||||
self.skillAPIKeyDrafts[skill.effectiveSkillKey] = ""
|
||||
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "API key cleared."
|
||||
: "API key saved."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func installSkillRequirements(_ skill: SkillStatusEntryLite) async {
|
||||
guard let installId = skill.install?.first?.id?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!installId.isEmpty
|
||||
else { return }
|
||||
await self.runSkillConfigMutation(skill) {
|
||||
let params = SkillInstallParams(name: skill.name, installId: installId, timeoutMs: 120_000)
|
||||
let data = try await self.requestGateway(
|
||||
method: "skills.install",
|
||||
params: params,
|
||||
timeoutSeconds: 125)
|
||||
return (try? JSONDecoder().decode(SkillInstallResultLite.self, from: data).message) ?? "Installed."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func installClawHubSkill(_ result: ClawHubSearchResultLite) async {
|
||||
guard liveGatewayConnected else { return }
|
||||
clawHubInstallSlug = result.slug
|
||||
clawHubErrorText = nil
|
||||
defer { self.clawHubInstallSlug = nil }
|
||||
do {
|
||||
let params = ClawHubInstallParams(slug: result.slug)
|
||||
_ = try await self.requestGateway(method: "skills.install", params: params, timeoutSeconds: 125)
|
||||
await appModel.refreshGatewayOverviewIfConnected()
|
||||
await refreshOverview(force: true)
|
||||
} catch {
|
||||
clawHubErrorText = Self.skillMutationMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func searchClawHubSkills() async {
|
||||
guard liveGatewayConnected else { return }
|
||||
clawHubLoading = true
|
||||
clawHubErrorText = nil
|
||||
defer { self.clawHubLoading = false }
|
||||
do {
|
||||
let query = clawHubQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let params = ClawHubSearchParams(query: query.isEmpty ? nil : query, limit: 20)
|
||||
let data = try await requestGateway(method: "skills.search", params: params, timeoutSeconds: 20)
|
||||
clawHubResults = try JSONDecoder().decode(ClawHubSearchResponseLite.self, from: data).results
|
||||
} catch {
|
||||
clawHubErrorText = Self.skillMutationMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runSkillConfigMutation(
|
||||
_ skill: SkillStatusEntryLite,
|
||||
action: () async throws -> String) async
|
||||
{
|
||||
guard liveGatewayConnected else { return }
|
||||
let key = skill.effectiveSkillKey
|
||||
skillConfigBusyKeys.insert(key)
|
||||
skillConfigMessages[key] = nil
|
||||
defer { self.skillConfigBusyKeys.remove(key) }
|
||||
|
||||
do {
|
||||
let message = try await action()
|
||||
skillConfigMessages[key] = SkillEditorMessage(kind: .success, text: message)
|
||||
await appModel.refreshGatewayOverviewIfConnected()
|
||||
await refreshOverview(force: true)
|
||||
} catch {
|
||||
skillConfigMessages[key] = SkillEditorMessage(
|
||||
kind: .error,
|
||||
text: Self.skillMutationMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
func requestGateway(
|
||||
method: String,
|
||||
params: some Encodable,
|
||||
timeoutSeconds: Int) async throws -> Data
|
||||
{
|
||||
guard liveGatewayConnected else {
|
||||
throw SkillMutationError.liveGatewayUnavailable
|
||||
}
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
}
|
||||
return try await appModel.operatorSession.request(
|
||||
method: method,
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
func requestConfigSnapshot() async throws -> ConfigSnapshotLite {
|
||||
guard liveGatewayConnected else {
|
||||
throw SkillMutationError.liveGatewayUnavailable
|
||||
}
|
||||
let data = try await appModel.operatorSession.request(
|
||||
method: "config.get",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 12)
|
||||
return try JSONDecoder().decode(ConfigSnapshotLite.self, from: data)
|
||||
}
|
||||
|
||||
static func agentSkillsPatchRaw(agentId: String, skills: [String]?) throws -> String {
|
||||
let skillValue: Any = skills ?? NSNull()
|
||||
let patch: [String: Any] = [
|
||||
"agents": [
|
||||
"list": [
|
||||
[
|
||||
"id": agentId,
|
||||
"skills": skillValue,
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: patch, options: [.sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
static func skillMutationMessage(_ error: Error) -> String {
|
||||
if let gatewayError = error as? GatewayResponseError {
|
||||
let lower = gatewayError.message.lowercased()
|
||||
if lower.contains("operator.admin") || lower.contains("unauthorized") {
|
||||
return "This gateway connection cannot edit config yet. Reconnect with admin scope."
|
||||
}
|
||||
return gatewayError.message
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
func skillStatus(_ skill: SkillStatusEntryLite) -> (text: String, color: Color) {
|
||||
if !self.isSkillAllowed(skill) {
|
||||
return ("off", .secondary)
|
||||
}
|
||||
if skill.blockedByAllowlist == true {
|
||||
return ("blocked", .secondary)
|
||||
}
|
||||
if skill.blockedByAgentFilter == true {
|
||||
return ("off", .secondary)
|
||||
}
|
||||
if skill.disabled == true {
|
||||
return ("disabled", .secondary)
|
||||
}
|
||||
if skill.hasMissingRequirements {
|
||||
return ("setup", OpenClawBrand.warn)
|
||||
}
|
||||
return ("enabled", OpenClawBrand.accent)
|
||||
}
|
||||
}
|
||||
83
apps/ios/Sources/Design/AgentProTab+Usage.swift
Normal file
83
apps/ios/Sources/Design/AgentProTab+Usage.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
var usageTotalsCard: some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Totals")
|
||||
.font(OpenClawType.headline)
|
||||
Spacer()
|
||||
ProValuePill(
|
||||
value: "\(self.overview?.usage?.days ?? 31)d",
|
||||
color: OpenClawBrand.accentForeground)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
self.detailMetric(label: "Cost", value: self.usageValue)
|
||||
self.detailMetric(label: "Tokens", value: self.usageTokenValue)
|
||||
self.detailMetric(label: "Cache", value: self.usageCacheValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var usageTokenValue: String {
|
||||
guard let tokens = self.overview?.usage?.totalTokens else { return "0" }
|
||||
return Self.compactNumber(tokens)
|
||||
}
|
||||
|
||||
var usageCacheValue: String {
|
||||
guard let cacheStatus = self.normalized(self.overview?.usage?.cacheStatus?["status"]?.value as? String) else {
|
||||
return "n/a"
|
||||
}
|
||||
return cacheStatus
|
||||
}
|
||||
|
||||
var usageDailyList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Daily")
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
let days = self.overview?.usage?.daily ?? []
|
||||
if days.isEmpty {
|
||||
self.emptyDetailRow(
|
||||
icon: "chart.bar",
|
||||
title: "No daily usage yet",
|
||||
detail: "The gateway returned totals without daily session cost rows.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(days.prefix(14).enumerated()), id: \.element.date) { index, day in
|
||||
self.usageDayRow(day)
|
||||
if index < min(days.count, 14) - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
func usageDayRow(_ day: CostUsageDailyEntryLite) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "calendar", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(day.date)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text("\(Self.compactNumber(day.totalTokens ?? 0)) tokens")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(Self.currency(day.totalCost ?? 0))
|
||||
.font(OpenClawType.caption2SemiBold)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
}
|
||||
188
apps/ios/Sources/Design/AgentProTab.swift
Normal file
188
apps/ios/Sources/Design/AgentProTab.swift
Normal file
@@ -0,0 +1,188 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct AgentProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
let directRoute: AgentRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String
|
||||
let openSettings: (() -> Void)?
|
||||
@State var navigationPath: [AgentRoute] = []
|
||||
@State var overview: AgentOverviewSnapshot?
|
||||
@State var overviewErrorText: String?
|
||||
@State var overviewLoading: Bool = false
|
||||
@State var agentRosterFilter: AgentRosterFilter = .all
|
||||
@State var agentSearchPresented = false
|
||||
@State var agentSearchText = ""
|
||||
@State var skillFilter: String = ""
|
||||
@State var skillStatusFilter: SkillStatusFilter = .all
|
||||
@State var skillMutationBusyKeys: Set<String> = []
|
||||
@State var skillMutationErrorText: String?
|
||||
@State var skillMutationStatusText: String?
|
||||
@State var skillConfigBusyKeys: Set<String> = []
|
||||
@State var skillConfigMessages: [String: SkillEditorMessage] = [:]
|
||||
@State var skillAPIKeyDrafts: [String: String] = [:]
|
||||
@State var skillEditorSelection: SkillEditorSelection?
|
||||
@State var clawHubQuery: String = ""
|
||||
@State var clawHubResults: [ClawHubSearchResultLite] = []
|
||||
@State var clawHubLoading: Bool = false
|
||||
@State var clawHubErrorText: String?
|
||||
@State var clawHubInstallSlug: String?
|
||||
@State var cronActionBusyIDs: Set<String> = []
|
||||
@State var cronActionStatusText: String?
|
||||
|
||||
enum AgentRoute: Hashable {
|
||||
case agents
|
||||
case skills
|
||||
case instances
|
||||
case cron
|
||||
case usage
|
||||
case dreaming
|
||||
}
|
||||
|
||||
enum SkillStatusFilter: String, CaseIterable, Identifiable {
|
||||
case all
|
||||
case enabled
|
||||
case off
|
||||
case setup
|
||||
case blocked
|
||||
|
||||
var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "All"
|
||||
case .enabled: "Enabled"
|
||||
case .off: "Off"
|
||||
case .setup: "Setup"
|
||||
case .blocked: "Blocked"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentRosterFilter: String, CaseIterable, Identifiable {
|
||||
case all
|
||||
case online
|
||||
case ready
|
||||
|
||||
var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "All"
|
||||
case .online: "Online"
|
||||
case .ready: "Ready"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .all: "person.2"
|
||||
case .online: "antenna.radiowaves.left.and.right"
|
||||
case .ready: "checkmark.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentLayout {
|
||||
static let cardRadius: CGFloat = OpenClawProMetric.cardRadius
|
||||
static let filterHeight: CGFloat = 34
|
||||
static let metricTileHeight: CGFloat = 94
|
||||
}
|
||||
|
||||
enum AgentRosterState: Equatable {
|
||||
case online
|
||||
case ready
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .online: OpenClawBrand.ok
|
||||
case .ready: OpenClawBrand.info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillEditorSelection: Identifiable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
struct SkillEditorMessage {
|
||||
let kind: Kind
|
||||
let text: String
|
||||
|
||||
enum Kind {
|
||||
case success
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
directRoute: AgentRoute? = nil,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
headerTitle: String = "Agents",
|
||||
openSettings: (() -> Void)? = nil)
|
||||
{
|
||||
self.directRoute = directRoute
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.headerTitle = headerTitle
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let directRoute {
|
||||
self.directDestination(for: directRoute)
|
||||
} else {
|
||||
self.overviewNavigation
|
||||
}
|
||||
}
|
||||
.task(id: self.overviewTaskID) {
|
||||
await self.refreshOverview(force: false)
|
||||
}
|
||||
.sheet(item: self.$skillEditorSelection) { selection in
|
||||
if let skill = self.skillByKey(selection.id) {
|
||||
self.skillEditorSheet(skill)
|
||||
} else {
|
||||
self.missingSkillEditorSheet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var overviewNavigation: some View {
|
||||
NavigationStack(path: self.$navigationPath) {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.rosterHeader
|
||||
self.agentFilters
|
||||
self.agentsSection
|
||||
self.operationsSection
|
||||
self.dreamingSection
|
||||
self.cronSection
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.navigationDestination(for: AgentRoute.self) { route in
|
||||
self.destination(for: route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func directDestination(for route: AgentRoute) -> some View {
|
||||
self.destination(for: route)
|
||||
.toolbar(
|
||||
route != .agents && self.directHeaderLeadingAction(for: route) != nil ? .hidden : .visible,
|
||||
for: .navigationBar)
|
||||
}
|
||||
}
|
||||
315
apps/ios/Sources/Design/ChatProTab.swift
Normal file
315
apps/ios/Sources/Design/ChatProTab.swift
Normal file
@@ -0,0 +1,315 @@
|
||||
import OpenClawChatUI
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct ChatProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@State private var viewModel: OpenClawChatViewModel?
|
||||
@State private var viewModelTransportModeID = ""
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String?
|
||||
let showsAgentBadge: Bool
|
||||
let ownsNavigationStack: Bool
|
||||
let openSettings: (() -> Void)?
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
headerTitle: String? = nil,
|
||||
showsAgentBadge: Bool = true,
|
||||
ownsNavigationStack: Bool = true,
|
||||
openSettings: (() -> Void)? = nil)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.headerTitle = headerTitle
|
||||
self.showsAgentBadge = showsAgentBadge
|
||||
self.ownsNavigationStack = ownsNavigationStack
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if self.ownsNavigationStack {
|
||||
NavigationStack {
|
||||
self.content
|
||||
}
|
||||
} else {
|
||||
// Phone and iPad hosts already provide a NavigationStack. Keep
|
||||
// one native bar so embedded Chat never grows duplicate chrome.
|
||||
self.content
|
||||
}
|
||||
}
|
||||
.task {
|
||||
self.syncChatViewModel()
|
||||
}
|
||||
.onChange(of: self.appModel.chatSessionKey) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
}
|
||||
.onChange(of: self.appModel.isAppleReviewDemoModeEnabled) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
}
|
||||
.onChange(of: self.appModel.isScreenshotFixtureModeEnabled) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
}
|
||||
.onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in
|
||||
guard connected else { return }
|
||||
self.syncChatViewModel()
|
||||
self.viewModel?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
self.chatSurface
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
.navigationTitle(self.headerDisplayTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if let headerLeadingAction {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
OpenClawSidebarRevealButton(action: headerLeadingAction)
|
||||
}
|
||||
}
|
||||
if self.showsAgentBadge {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
self.headerIdentityBadge
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
self.connectionStatusButton
|
||||
.accessibilityIdentifier("chat-gateway-status")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chatSurface: some View {
|
||||
if let viewModel {
|
||||
OpenClawChatView(
|
||||
viewModel: viewModel,
|
||||
drawsBackground: false,
|
||||
showsSessionSwitcher: false,
|
||||
userAccent: self.chatUserAccent,
|
||||
assistantName: self.agentDisplayName,
|
||||
assistantAvatarText: self.agentBadge,
|
||||
assistantAvatarTint: OpenClawBrand.accent,
|
||||
showsAssistantAvatars: false,
|
||||
composerChrome: .clean,
|
||||
isComposerEnabled: self.gatewayConnected,
|
||||
messagePlaceholder: self.messagePlaceholder,
|
||||
emptyAssistantIntro: String(localized: "What would you like to work on?"),
|
||||
emptyAssistantPrompts: Self.emptyAssistantPrompts,
|
||||
talkControl: self.talkControl)
|
||||
// iMessage-style grey bubbles for agent replies in the clean chrome.
|
||||
.environment(\.openClawAssistantBubblesInCleanChrome, true)
|
||||
.id(ObjectIdentifier(viewModel))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Preparing Chat",
|
||||
systemImage: "bubble.left.and.bubble.right",
|
||||
description: Text("The session attaches once the gateway is ready."))
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat circular avatar for the nav bar — no gradient/shadow, per Apple bar-button sizing.
|
||||
private var headerIdentityBadge: some View {
|
||||
Text(self.agentBadge)
|
||||
.font(OpenClawType.avatar(size: self.agentBadge.count > 2 ? 12 : 15))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.6)
|
||||
.lineLimit(1)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Circle().fill(OpenClawBrand.accent))
|
||||
.accessibilityLabel(self.agentDisplayName)
|
||||
}
|
||||
|
||||
private func syncChatViewModel() {
|
||||
let sessionKey = self.appModel.chatSessionKey
|
||||
let transportModeID = self.appModel.chatTransportModeID
|
||||
guard let viewModel else {
|
||||
self.viewModelTransportModeID = transportModeID
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: self.appModel.makeChatTransport(),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
diagnosticsLog: { message in
|
||||
GatewayDiagnostics.log(message)
|
||||
})
|
||||
return
|
||||
}
|
||||
if self.viewModelTransportModeID != transportModeID {
|
||||
self.viewModelTransportModeID = transportModeID
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: self.appModel.makeChatTransport(),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
diagnosticsLog: { message in
|
||||
GatewayDiagnostics.log(message)
|
||||
})
|
||||
return
|
||||
}
|
||||
guard viewModel.sessionKey != sessionKey else { return }
|
||||
viewModel.syncSession(to: sessionKey)
|
||||
}
|
||||
|
||||
private var talkControl: OpenClawChatTalkControl {
|
||||
OpenClawChatTalkControl(
|
||||
isEnabled: self.appModel.talkMode.isEnabled,
|
||||
isListening: self.appModel.talkMode.isListening,
|
||||
isSpeaking: self.appModel.talkMode.isSpeaking,
|
||||
isGatewayConnected: self.appModel.talkMode.isGatewayConnected,
|
||||
statusText: self.appModel.talkMode.statusText,
|
||||
providerLabel: self.appModel.talkMode.gatewayTalkProviderLabel,
|
||||
toggle: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
self.appModel.setTalkEnabled(!self.appModel.talkMode.isEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
private var activeAgentID: String {
|
||||
self.normalized(self.appModel.chatAgentId)
|
||||
?? "main"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var connectionStatusButton: some View {
|
||||
if let openSettings {
|
||||
Button(action: openSettings) {
|
||||
self.connectionPill
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(self.gatewayAccessibilityLabel)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
self.connectionPill
|
||||
.accessibilityLabel(self.gatewayAccessibilityLabel)
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionPill: some View {
|
||||
HStack(spacing: 5) {
|
||||
ProStatusDot(color: self.gatewayPillColor)
|
||||
Text(Self.gatewayPillTitle(state: self.gatewayDisplayState, isGatewayUsable: self.gatewayConnected))
|
||||
.font(OpenClawType.subheadMedium)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(self.gatewayPillColor)
|
||||
// Even breathing room inside the system glass capsule.
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
guard self.gatewayDisplayState == .connected else {
|
||||
return false
|
||||
}
|
||||
return self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var gatewayDisplayState: GatewayDisplayState {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
}
|
||||
|
||||
private var gatewayAccessibilityLabel: String {
|
||||
"Gateway: \(Self.gatewayPillTitle(state: self.gatewayDisplayState, isGatewayUsable: self.gatewayConnected))"
|
||||
}
|
||||
|
||||
private var gatewayPillColor: Color {
|
||||
switch self.gatewayDisplayState {
|
||||
case .connected:
|
||||
self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func gatewayPillTitle(state: GatewayDisplayState, isGatewayUsable: Bool) -> String {
|
||||
switch state {
|
||||
case .connected:
|
||||
isGatewayUsable ? "Connected" : "Unavailable"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var messagePlaceholder: String {
|
||||
self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway"
|
||||
}
|
||||
|
||||
private var headerDisplayTitle: String {
|
||||
self.normalized(self.headerTitle)
|
||||
?? Self.defaultHeaderTitle(showsAgentBadge: self.showsAgentBadge, agentDisplayName: self.agentDisplayName)
|
||||
}
|
||||
|
||||
nonisolated static func defaultHeaderTitle(showsAgentBadge: Bool, agentDisplayName: String) -> String {
|
||||
showsAgentBadge ? agentDisplayName : "Chat"
|
||||
}
|
||||
|
||||
private var chatUserAccent: Color {
|
||||
OpenClawBrand.accent
|
||||
}
|
||||
|
||||
private var activeAgent: AgentSummary? {
|
||||
self.appModel.gatewayAgents.first { $0.id == self.activeAgentID }
|
||||
}
|
||||
|
||||
private var agentDisplayName: String {
|
||||
self.normalized(self.activeAgent?.name) ?? self.appModel.chatAgentName
|
||||
}
|
||||
|
||||
private var agentBadge: String {
|
||||
if let identity = activeAgent?.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
let normalizedEmoji = Self.normalizedBadgeEmoji(emoji)
|
||||
{
|
||||
return normalizedEmoji
|
||||
}
|
||||
let words = self.agentDisplayName
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||
.prefix(2)
|
||||
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||
if !initials.isEmpty {
|
||||
return initials.uppercased()
|
||||
}
|
||||
return "OC"
|
||||
}
|
||||
|
||||
nonisolated static func normalizedBadgeEmoji(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return normalized.isEmpty || normalized == "?" ? nil : normalized
|
||||
}
|
||||
|
||||
nonisolated static let emptyAssistantPrompts: [OpenClawChatView.StarterPrompt] = [
|
||||
OpenClawChatView.StarterPrompt(
|
||||
id: "summarize-status",
|
||||
title: String(localized: "Check OpenClaw status"),
|
||||
prompt: String(localized: "Summarize the current OpenClaw status and tell me what needs attention.")),
|
||||
OpenClawChatView.StarterPrompt(
|
||||
id: "show-controls",
|
||||
title: String(localized: "What can I control here?"),
|
||||
prompt: String(localized: "Show me which phone controls and device capabilities are available right now.")),
|
||||
OpenClawChatView.StarterPrompt(
|
||||
id: "start-voice",
|
||||
title: String(localized: "Help me start voice chat"),
|
||||
prompt: String(localized: "Help me start a realtime voice session from this phone.")),
|
||||
]
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
137
apps/ios/Sources/Design/CommandCenterSupport.swift
Normal file
137
apps/ios/Sources/Design/CommandCenterSupport.swift
Normal file
@@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommandPanel<Content: View>: View {
|
||||
var tint: Color?
|
||||
var isProminent = false
|
||||
var padding: CGFloat = 13
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
init(
|
||||
tint: Color? = nil,
|
||||
isProminent: Bool = false,
|
||||
padding: CGFloat = 13,
|
||||
@ViewBuilder content: () -> Content)
|
||||
{
|
||||
self.tint = tint
|
||||
self.isProminent = isProminent
|
||||
self.padding = padding
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ProCard(
|
||||
tint: self.tint,
|
||||
isProminent: self.isProminent,
|
||||
padding: self.padding,
|
||||
radius: OpenClawProMetric.cardRadius)
|
||||
{
|
||||
self.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandControlBackground: View {
|
||||
var body: some View {
|
||||
OpenClawProBackground()
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandSessionRow: View {
|
||||
let item: CommandCenterTab.WorkItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: self.item.icon)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(self.item.color)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous)
|
||||
.fill(self.item.color.opacity(0.12))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.item.title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.82)
|
||||
Spacer(minLength: 6)
|
||||
Text(self.item.trailing)
|
||||
.font(OpenClawType.caption2Medium)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(self.item.detail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 6)
|
||||
if let progress = self.item.progress {
|
||||
ProProgressBar(progress: progress, color: self.item.color)
|
||||
.frame(width: 68)
|
||||
}
|
||||
Text(self.progressLabel)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(self.item.color)
|
||||
.lineLimit(1)
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
guard let progress = item.progress else {
|
||||
return self.item.state
|
||||
}
|
||||
if self.item.state == "offline" || self.item.state == "off" || self.item.state == "idle" {
|
||||
return self.item.state
|
||||
}
|
||||
return "\(Int((progress * 100).rounded()))%"
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandViewMoreRow: View {
|
||||
var body: some View {
|
||||
Label("View More", systemImage: "chevron.right")
|
||||
.font(OpenClawType.subheadBold)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandEmptyStateRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: self.icon)
|
||||
.font(OpenClawType.captionBold)
|
||||
.foregroundStyle(OpenClawBrand.ok)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: OpenClawRadius.xs, style: .continuous)
|
||||
.fill(OpenClawBrand.ok.opacity(0.10))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
Text(self.detail)
|
||||
.font(OpenClawType.caption2Medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
790
apps/ios/Sources/Design/CommandCenterTab.swift
Normal file
790
apps/ios/Sources/Design/CommandCenterTab.swift
Normal file
@@ -0,0 +1,790 @@
|
||||
import OpenClawChatUI
|
||||
import SwiftUI
|
||||
|
||||
struct CommandCenterTab: View {
|
||||
static let recentSessionsFetchLimit = 200
|
||||
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var defaultChatSessionEntry: OpenClawChatSessionEntry?
|
||||
@State private var recentChatSessions: [OpenClawChatSessionEntry] = []
|
||||
var ownsNavigationStack: Bool = true
|
||||
var usesNativeNavigationChrome: Bool = false
|
||||
var headerTitle: String = "OpenClaw"
|
||||
var headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
var showsHeaderMark: Bool = true
|
||||
var openChat: () -> Void
|
||||
var openSettings: () -> Void
|
||||
var openSessions: (() -> Void)?
|
||||
|
||||
enum WorkRoute {
|
||||
case chat(String?)
|
||||
case settings
|
||||
}
|
||||
|
||||
struct WorkItem: Identifiable {
|
||||
let id: String
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let state: String
|
||||
let trailing: String
|
||||
let color: Color
|
||||
let progress: Double?
|
||||
let route: WorkRoute
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if self.ownsNavigationStack {
|
||||
NavigationStack {
|
||||
self.content
|
||||
}
|
||||
} else {
|
||||
self.content
|
||||
}
|
||||
}
|
||||
.task(id: self.recentSessionsRefreshID) {
|
||||
await self.refreshRecentSessionsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
self.commandAmbientOverlay
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if !self.usesNativeNavigationChrome {
|
||||
self.header
|
||||
}
|
||||
self.gatewayCard
|
||||
if Self.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: self.horizontalSizeClass,
|
||||
containerWidth: geometry.size.width)
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.defaultChatSessionSection
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
self.recentSessions
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
} else {
|
||||
self.defaultChatSessionSection
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
self.recentSessions
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
}
|
||||
.navigationTitle(self.headerTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(self.usesNativeNavigationChrome ? .visible : .hidden, for: .navigationBar)
|
||||
.toolbar {
|
||||
if self.usesNativeNavigationChrome {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(action: self.openSettings) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
}
|
||||
.accessibilityLabel("Gateway settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func usesSplitSectionsLayout(
|
||||
horizontalSizeClass: UserInterfaceSizeClass?,
|
||||
containerWidth: CGFloat) -> Bool
|
||||
{
|
||||
guard horizontalSizeClass == .regular else { return false }
|
||||
return containerWidth >= 1000
|
||||
}
|
||||
|
||||
static func shouldShowHeaderMark(
|
||||
hasLeadingAction: Bool,
|
||||
showsHeaderMark: Bool) -> Bool
|
||||
{
|
||||
!hasLeadingAction && showsHeaderMark
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.headerTitle,
|
||||
subtitle: self.gatewaySubtitle,
|
||||
titleFont: OpenClawType.title3SemiBold,
|
||||
subtitleFont: OpenClawType.caption,
|
||||
subtitleLineLimit: 1)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} else if Self.shouldShowHeaderMark(
|
||||
hasLeadingAction: headerLeadingAction != nil,
|
||||
showsHeaderMark: self.showsHeaderMark)
|
||||
{
|
||||
OpenClawProMark(size: 28, shadowRadius: 5)
|
||||
}
|
||||
} accessory: {
|
||||
Button(action: self.openSettings) {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.frame(width: OpenClawProMetric.compactControlSize, height: OpenClawProMetric.compactControlSize)
|
||||
}
|
||||
.openClawGlassButton()
|
||||
.accessibilityLabel("Gateway settings")
|
||||
.accessibilityHint("Opens gateway settings")
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var commandAmbientOverlay: some View {
|
||||
Group {
|
||||
if self.colorScheme == .light {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.05),
|
||||
Color.clear,
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayCard: some View {
|
||||
CommandPanel(isProminent: true, padding: 12) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.cardHeader(title: "Gateway")
|
||||
|
||||
HStack(spacing: 0) {
|
||||
self.gatewayFact(
|
||||
icon: "network",
|
||||
title: "Connection",
|
||||
value: self.gatewayConnectionText,
|
||||
color: self.gatewayStatusColor)
|
||||
Divider().frame(height: 38)
|
||||
self.gatewayFact(
|
||||
icon: "server.rack",
|
||||
title: "Address",
|
||||
value: self.gatewayAddressText,
|
||||
color: OpenClawBrand.accentForeground)
|
||||
Divider().frame(height: 38)
|
||||
self.gatewayFact(
|
||||
icon: "person.2.fill",
|
||||
title: "Agents",
|
||||
value: self.gatewayAgentCountText,
|
||||
color: OpenClawBrand.accentHotForeground)
|
||||
}
|
||||
.padding(.vertical, 7)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func gatewayFact(icon: String, title: String, value: String, color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: icon)
|
||||
.font(OpenClawType.caption2Bold)
|
||||
.foregroundStyle(color)
|
||||
Text(title)
|
||||
.font(OpenClawType.caption2Medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(value)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(title == "Connection" ? color : .primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
|
||||
private var defaultChatSessionSection: some View {
|
||||
CommandPanel(padding: 12) {
|
||||
VStack(spacing: 10) {
|
||||
self.cardHeader(title: "Agent session")
|
||||
|
||||
Button {
|
||||
self.open(.chat(nil))
|
||||
} label: {
|
||||
CommandSessionRow(item: self.defaultChatWorkItem)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var recentSessions: some View {
|
||||
CommandPanel(padding: 12) {
|
||||
VStack(spacing: 10) {
|
||||
self.cardHeader(title: "Recent sessions")
|
||||
|
||||
if self.recentSessionPreviewRows.isEmpty {
|
||||
CommandEmptyStateRow(
|
||||
icon: self.gatewayConnected ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash",
|
||||
title: self.gatewayConnected ? "No recent sessions" : "Gateway offline",
|
||||
detail: self
|
||||
.gatewayConnected ? "Start a chat and it will appear here." : "Connect to the gateway.")
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(self.recentSessionPreviewRows) { item in
|
||||
Button {
|
||||
self.open(item.route)
|
||||
} label: {
|
||||
CommandSessionRow(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if self.hasMoreRecentSessions {
|
||||
if let openSessions {
|
||||
Button(action: openSessions) {
|
||||
CommandViewMoreRow()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
NavigationLink {
|
||||
CommandSessionsScreen(
|
||||
usesNativeNavigationChrome: self.usesNativeNavigationChrome,
|
||||
openChat: self.openChat)
|
||||
} label: {
|
||||
CommandViewMoreRow()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cardHeader(title: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
self.gatewayDisplayState == .connected
|
||||
}
|
||||
|
||||
private var gatewayDisplayState: GatewayDisplayState {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
}
|
||||
|
||||
private var gatewayConnectionText: String {
|
||||
switch self.gatewayDisplayState {
|
||||
case .connected:
|
||||
"Online"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStatusColor: Color {
|
||||
switch self.gatewayDisplayState {
|
||||
case .connected:
|
||||
OpenClawBrand.ok
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayAddressText: String {
|
||||
self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
?? self.normalized(self.appModel.gatewayServerName)
|
||||
?? "Unknown"
|
||||
}
|
||||
|
||||
private var gatewayAgentCountText: String {
|
||||
guard self.gatewayConnected else { return "—" }
|
||||
return "\(self.appModel.gatewayAgents.count)"
|
||||
}
|
||||
|
||||
private var defaultChatWorkItem: WorkItem {
|
||||
let isOpen = self.appModel.chatSessionKey == self.appModel.defaultChatSessionKey
|
||||
return WorkItem(
|
||||
id: "default-chat",
|
||||
icon: isOpen ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
|
||||
title: self.appModel.activeAgentName,
|
||||
detail: self.defaultChatActivityText,
|
||||
state: isOpen ? "open" : "default",
|
||||
trailing: "chat",
|
||||
color: isOpen ? OpenClawBrand.accent : OpenClawBrand.ok,
|
||||
progress: nil,
|
||||
route: .chat(nil))
|
||||
}
|
||||
|
||||
private var defaultChatActivityText: String {
|
||||
guard let updatedAt = defaultChatSessionEntry?.updatedAt, updatedAt > 0 else {
|
||||
return "No recent activity"
|
||||
}
|
||||
return Self.relativeTimeText(forMilliseconds: updatedAt)
|
||||
}
|
||||
|
||||
private var recentSessionRows: [WorkItem] {
|
||||
self.sessionItems
|
||||
}
|
||||
|
||||
private var recentSessionPreviewRows: [WorkItem] {
|
||||
Array(self.recentSessionRows.prefix(3))
|
||||
}
|
||||
|
||||
private var hasMoreRecentSessions: Bool {
|
||||
self.sessionWorkItems.count > self.recentSessionPreviewRows.count
|
||||
}
|
||||
|
||||
private var recentSessionsRefreshID: String {
|
||||
[
|
||||
self.sessionListMode,
|
||||
self.appModel.chatSessionKey,
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
private var sessionListAvailable: Bool {
|
||||
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionListMode: String {
|
||||
self.appModel.chatTransportModeID
|
||||
}
|
||||
|
||||
private var sessionItems: [WorkItem] {
|
||||
self.sessionWorkItems
|
||||
}
|
||||
|
||||
private var sessionWorkItems: [WorkItem] {
|
||||
let currentSessionKey = self.appModel.chatSessionKey
|
||||
return self.recentChatSessions
|
||||
.filter { Self.isRecentChatSession($0.key, defaultSessionKey: self.appModel.defaultChatSessionKey) }
|
||||
.map { session in
|
||||
Self.sessionWorkItem(for: session, currentSessionKey: currentSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ route: WorkRoute) {
|
||||
switch route {
|
||||
case let .chat(sessionKey):
|
||||
self.appModel.openChat(sessionKey: sessionKey)
|
||||
self.openChat()
|
||||
case .settings:
|
||||
self.openSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshRecentSessionsIfNeeded() async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.sessionListAvailable else {
|
||||
if self.defaultChatSessionEntry != nil {
|
||||
self.defaultChatSessionEntry = nil
|
||||
}
|
||||
if !self.recentChatSessions.isEmpty {
|
||||
self.recentChatSessions = []
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit)
|
||||
self.defaultChatSessionEntry = response.sessions.first {
|
||||
$0.key == self.appModel.defaultChatSessionKey
|
||||
}
|
||||
self.recentChatSessions = Self.sessionChoices(
|
||||
response.sessions,
|
||||
currentSessionKey: self.appModel.chatSessionKey,
|
||||
defaultSessionKey: self.appModel.defaultChatSessionKey)
|
||||
} catch {
|
||||
self.defaultChatSessionEntry = nil
|
||||
self.recentChatSessions = []
|
||||
}
|
||||
}
|
||||
|
||||
private static func sessionChoices(
|
||||
_ sessions: [OpenClawChatSessionEntry],
|
||||
currentSessionKey: String,
|
||||
defaultSessionKey: String) -> [OpenClawChatSessionEntry]
|
||||
{
|
||||
let sorted = sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
var result: [OpenClawChatSessionEntry] = []
|
||||
var included = Set<String>()
|
||||
|
||||
if Self.isRecentChatSession(currentSessionKey, defaultSessionKey: defaultSessionKey),
|
||||
let current = sorted.first(where: { $0.key == currentSessionKey })
|
||||
{
|
||||
result.append(current)
|
||||
included.insert(current.key)
|
||||
}
|
||||
|
||||
for session in sorted {
|
||||
guard !included.contains(session.key) else { continue }
|
||||
guard Self.isRecentChatSession(session.key, defaultSessionKey: defaultSessionKey) else { continue }
|
||||
result.append(session)
|
||||
included.insert(session.key)
|
||||
if result.count >= 4 { break }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
static func sessionWorkItem(
|
||||
for session: OpenClawChatSessionEntry,
|
||||
currentSessionKey: String) -> WorkItem
|
||||
{
|
||||
let isCurrent = session.key == currentSessionKey
|
||||
return WorkItem(
|
||||
id: "chat-session-\(session.key)",
|
||||
icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
|
||||
title: Self.sessionTitle(session),
|
||||
detail: Self.sessionDetail(session),
|
||||
state: isCurrent ? "open" : "recent",
|
||||
trailing: "chat",
|
||||
color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok,
|
||||
progress: nil,
|
||||
route: .chat(session.key))
|
||||
}
|
||||
|
||||
fileprivate static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String {
|
||||
if let title = redactedSessionTitle(for: session.key) {
|
||||
return title
|
||||
}
|
||||
|
||||
let displayName = session.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let displayName, !displayName.isEmpty {
|
||||
return Self.redactedSessionTitle(for: displayName) ?? displayName
|
||||
}
|
||||
let subject = session.subject?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let subject, !subject.isEmpty {
|
||||
return Self.redactedSessionTitle(for: subject) ?? subject
|
||||
}
|
||||
return session.key
|
||||
}
|
||||
|
||||
fileprivate static func redactedSessionTitle(for key: String) -> String? {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lowercased = trimmed.lowercased()
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if lowercased.contains(":ios-") {
|
||||
return "iOS chat"
|
||||
}
|
||||
if lowercased.hasPrefix("telegram:") {
|
||||
return "Telegram chat"
|
||||
}
|
||||
if lowercased.hasPrefix("user:+") {
|
||||
return "Direct chat"
|
||||
}
|
||||
if lowercased.hasPrefix("cron:") {
|
||||
return Self.humanizedSessionKey(String(trimmed.dropFirst("cron:".count)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fileprivate static func humanizedSessionKey(_ key: String) -> String? {
|
||||
let words = key
|
||||
.replacingOccurrences(of: "_", with: "-")
|
||||
.split(separator: "-")
|
||||
.map(String.init)
|
||||
.filter { !$0.isEmpty }
|
||||
guard !words.isEmpty else { return nil }
|
||||
|
||||
return words
|
||||
.map { word in
|
||||
switch word.lowercased() {
|
||||
case "ai", "api", "ios", "qmd", "url":
|
||||
word.uppercased()
|
||||
default:
|
||||
word.prefix(1).uppercased() + String(word.dropFirst())
|
||||
}
|
||||
}
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
fileprivate static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String {
|
||||
if let updatedAt = session.updatedAt, updatedAt > 0 {
|
||||
return self.relativeTimeText(forMilliseconds: updatedAt)
|
||||
}
|
||||
return session.key
|
||||
}
|
||||
|
||||
fileprivate static func relativeTimeText(forMilliseconds milliseconds: Double) -> String {
|
||||
let date = Date(timeIntervalSince1970: milliseconds / 1000)
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.dateTimeStyle = .numeric
|
||||
formatter.unitsStyle = .short
|
||||
return formatter.localizedString(for: date, relativeTo: .now)
|
||||
}
|
||||
|
||||
fileprivate nonisolated static func isHiddenInternalSession(_ key: String) -> Bool {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
|
||||
}
|
||||
|
||||
nonisolated static func isRecentChatSession(_ key: String, defaultSessionKey: String) -> Bool {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
if trimmed == defaultSessionKey { return false }
|
||||
let normalized = trimmed.lowercased()
|
||||
let defaultBase = self.sessionBaseKey(defaultSessionKey)
|
||||
if !normalized.contains(":"),
|
||||
self.isDirectSessionBase(normalized, defaultBase: defaultBase)
|
||||
{
|
||||
return false
|
||||
}
|
||||
if self.isHiddenInternalSession(trimmed) { return false }
|
||||
return !self.isAgentDeviceSession(trimmed, defaultSessionKey: defaultSessionKey)
|
||||
}
|
||||
|
||||
private nonisolated static func isAgentDeviceSession(_ key: String, defaultSessionKey: String) -> Bool {
|
||||
let parts = key
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard parts.count >= 3, parts[0].lowercased() == "agent" else { return false }
|
||||
guard parts.count == 3 || parts[3].lowercased() == "thread" else { return false }
|
||||
|
||||
let base = String(parts[2]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let defaultKey = self.sessionBaseKey(defaultSessionKey)
|
||||
return self.isDirectSessionBase(base, defaultBase: defaultKey)
|
||||
}
|
||||
|
||||
private nonisolated static func isDirectSessionBase(_ base: String, defaultBase: String) -> Bool {
|
||||
base == defaultBase || base == "main" || base == "global" || base.hasPrefix("node-")
|
||||
}
|
||||
|
||||
private nonisolated static func sessionBaseKey(_ key: String) -> String {
|
||||
let parts = key
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard parts.count >= 3, parts[0].lowercased() == "agent" else {
|
||||
return key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
return String(parts[2]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
private var gatewaySubtitle: String {
|
||||
if let server = normalized(appModel.gatewayServerName) {
|
||||
return "\(self.appModel.activeAgentName) on \(server)"
|
||||
}
|
||||
if let address = normalized(appModel.gatewayRemoteAddress) {
|
||||
return "\(self.appModel.activeAgentName) via \(address)"
|
||||
}
|
||||
return self.appModel.gatewayDisplayStatusText
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
Self.normalized(value)
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandSessionsScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var sessions: [OpenClawChatSessionEntry] = []
|
||||
@State private var isLoading = false
|
||||
@State private var loadErrorText: String?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let usesNativeNavigationChrome: Bool
|
||||
let openChat: () -> Void
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
usesNativeNavigationChrome: Bool = false,
|
||||
openChat: @escaping () -> Void)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.usesNativeNavigationChrome = usesNativeNavigationChrome
|
||||
self.openChat = openChat
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if !self.usesNativeNavigationChrome {
|
||||
self.header
|
||||
}
|
||||
self.sessionsPanel
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Sessions")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(self.usesNativeNavigationChrome ? .visible : .hidden, for: .navigationBar)
|
||||
.task(id: self.refreshID) {
|
||||
await self.refreshSessions()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sessions")
|
||||
.font(OpenClawType.title2)
|
||||
Text(self.headerDetail)
|
||||
.font(OpenClawType.captionMedium)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var sessionsPanel: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
Text("Recent sessions")
|
||||
.font(OpenClawType.subheadBold)
|
||||
Spacer(minLength: 8)
|
||||
if self.isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 3)
|
||||
|
||||
if let loadErrorText {
|
||||
CommandEmptyStateRow(
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
title: "Sessions unavailable",
|
||||
detail: loadErrorText)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
} else if self.sessionRows.isEmpty {
|
||||
CommandEmptyStateRow(
|
||||
icon: self.appModel
|
||||
.isCommandSessionListAvailable ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash",
|
||||
title: self.appModel.isCommandSessionListAvailable ? "No recent sessions" : "Gateway offline",
|
||||
detail: self.appModel
|
||||
.isCommandSessionListAvailable ? "Start a chat and it will appear here." :
|
||||
"Connect to the gateway.")
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(self.sessionRows) { item in
|
||||
Button {
|
||||
self.open(item)
|
||||
} label: {
|
||||
CommandSessionRow(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var headerDetail: String {
|
||||
if self.isLoading, self.sessions.isEmpty { return "Loading recent sessions" }
|
||||
let count = self.sessionRows.count
|
||||
if count == 0 {
|
||||
return self.appModel.isCommandSessionListAvailable ? "No recent sessions" : "Gateway offline"
|
||||
}
|
||||
return "\(count) \(count == 1 ? "session" : "sessions")"
|
||||
}
|
||||
|
||||
private var sessionRows: [CommandCenterTab.WorkItem] {
|
||||
self.sessions
|
||||
.filter { CommandCenterTab.isRecentChatSession(
|
||||
$0.key,
|
||||
defaultSessionKey: self.appModel.defaultChatSessionKey) }
|
||||
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
.map {
|
||||
CommandCenterTab.sessionWorkItem(
|
||||
for: $0,
|
||||
currentSessionKey: self.appModel.chatSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private var refreshID: String {
|
||||
self.appModel.commandSessionListMode
|
||||
}
|
||||
|
||||
private func open(_ item: CommandCenterTab.WorkItem) {
|
||||
switch item.route {
|
||||
case let .chat(sessionKey):
|
||||
self.appModel.openChat(sessionKey: sessionKey)
|
||||
self.dismiss()
|
||||
self.openChat()
|
||||
case .settings:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSessions() async {
|
||||
guard self.appModel.isCommandSessionListAvailable else {
|
||||
self.sessions = []
|
||||
self.loadErrorText = nil
|
||||
return
|
||||
}
|
||||
|
||||
self.isLoading = true
|
||||
self.loadErrorText = nil
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
self.sessions = []
|
||||
self.loadErrorText = "Try again after the gateway reconnects."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
fileprivate var isCommandSessionListAvailable: Bool {
|
||||
self.isLocalChatFixtureEnabled || self.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
fileprivate var commandSessionListMode: String {
|
||||
self.chatTransportModeID
|
||||
}
|
||||
}
|
||||
245
apps/ios/Sources/Design/IPadActivityScreen.swift
Normal file
245
apps/ios/Sources/Design/IPadActivityScreen.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct IPadActivityScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var sessions: [OpenClawChatSessionEntry] = []
|
||||
@State private var isLoading = false
|
||||
@State private var loadErrorText: String?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let usesNativeNavigationChrome: Bool
|
||||
let openChat: () -> Void
|
||||
let openSettings: () -> Void
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
usesNativeNavigationChrome: Bool = false,
|
||||
openChat: @escaping () -> Void,
|
||||
openSettings: @escaping () -> Void)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.usesNativeNavigationChrome = usesNativeNavigationChrome
|
||||
self.openChat = openChat
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
IPadSidebarScreenChrome(
|
||||
title: "Activity",
|
||||
subtitle: "Live device and gateway activity.",
|
||||
headerLeadingAction: self.headerLeadingAction,
|
||||
usesNativeNavigationChrome: self.usesNativeNavigationChrome,
|
||||
gatewayAction: self.openSettings)
|
||||
{
|
||||
ProMetricGrid(metrics: self.metrics)
|
||||
self.activityFeed
|
||||
}
|
||||
.task(id: self.refreshID) {
|
||||
await self.refreshSessions()
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshSessions()
|
||||
}
|
||||
}
|
||||
|
||||
private var metrics: [ProMetric] {
|
||||
[
|
||||
ProMetric(
|
||||
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash",
|
||||
title: "Gateway",
|
||||
value: self.gatewayStateText,
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary),
|
||||
ProMetric(
|
||||
icon: "person.2.fill",
|
||||
title: "Agents",
|
||||
value: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count)" : "offline",
|
||||
color: OpenClawBrand.accentForeground),
|
||||
ProMetric(
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "Sessions",
|
||||
value: self.isLoading ? "..." : "\(self.sessionRows.count)",
|
||||
color: OpenClawBrand.accentHotForeground),
|
||||
]
|
||||
}
|
||||
|
||||
private var activityFeed: some View {
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Recent activity",
|
||||
value: self.isLoading ? "Loading" : nil,
|
||||
actionTitle: "Refresh",
|
||||
action: {
|
||||
Task { await self.refreshSessions() }
|
||||
})
|
||||
|
||||
if let pendingExecApprovalPrompt = self.appModel.pendingExecApprovalPrompt {
|
||||
ProStatusRow(
|
||||
icon: "hand.raised.fill",
|
||||
title: "Approval needed",
|
||||
detail: pendingExecApprovalPrompt.commandPreview ?? pendingExecApprovalPrompt.commandText,
|
||||
value: "pending",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
|
||||
ProStatusRow(
|
||||
icon: self.gatewayConnected ? "network" : "wifi.slash",
|
||||
title: "Gateway",
|
||||
detail: self.gatewayDetailText,
|
||||
value: self.gatewayStateText.lowercased(),
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
|
||||
actionTitle: self.gatewayConnected ? nil : "Settings",
|
||||
action: self.gatewayConnected ? nil : self.openSettings)
|
||||
|
||||
Divider().padding(.leading, 58)
|
||||
|
||||
ProStatusRow(
|
||||
icon: "square.and.arrow.down",
|
||||
title: "Share intake",
|
||||
detail: self.appModel.lastShareEventText,
|
||||
value: "iPad",
|
||||
color: OpenClawBrand.accentForeground,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
|
||||
if self.isLoading, self.sessions.isEmpty {
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading sessions",
|
||||
detail: "Fetching recent activity from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accentForeground,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
} else if let loadErrorText {
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
title: "Sessions unavailable",
|
||||
detail: loadErrorText,
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
} else if self.sessionRows.isEmpty {
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: self.sessionsAvailable ? "No recent sessions" : "Session activity offline",
|
||||
detail: self.sessionsAvailable
|
||||
? "Start a chat and it will appear here."
|
||||
: "Connect to the gateway to load recent chat activity.",
|
||||
value: self.sessionsAvailable ? "empty" : "offline",
|
||||
color: .secondary,
|
||||
actionTitle: self.sessionsAvailable ? "Chat" : nil,
|
||||
action: self.sessionsAvailable ? self.openChat : nil)
|
||||
} else {
|
||||
ForEach(self.sessionRows) { row in
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: row.icon,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
value: row.state,
|
||||
color: row.color,
|
||||
actionTitle: "Open",
|
||||
action: {
|
||||
self.open(row)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var refreshID: String {
|
||||
[
|
||||
self.sessionsMode,
|
||||
self.appModel.chatSessionKey,
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var gatewayStateText: String {
|
||||
guard !self.gatewayConnected else { return "Online" }
|
||||
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return status.isEmpty ? "Offline" : status
|
||||
}
|
||||
|
||||
private var gatewayDetailText: String {
|
||||
self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
?? self.normalized(self.appModel.gatewayServerName)
|
||||
?? "No gateway connection"
|
||||
}
|
||||
|
||||
private var sessionsAvailable: Bool {
|
||||
self.appModel.isLocalChatFixtureEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionsMode: String {
|
||||
self.appModel.chatTransportModeID
|
||||
}
|
||||
|
||||
private var sessionRows: [CommandCenterTab.WorkItem] {
|
||||
self.sessions
|
||||
.filter { CommandCenterTab.isRecentChatSession(
|
||||
$0.key,
|
||||
defaultSessionKey: self.appModel.defaultChatSessionKey) }
|
||||
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
.prefix(8)
|
||||
.map {
|
||||
CommandCenterTab.sessionWorkItem(
|
||||
for: $0,
|
||||
currentSessionKey: self.appModel.chatSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSessions() async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.sessionsAvailable else {
|
||||
self.sessions = []
|
||||
self.loadErrorText = nil
|
||||
return
|
||||
}
|
||||
|
||||
self.isLoading = true
|
||||
self.loadErrorText = nil
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport = self.appModel.makeChatTransport()
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
self.sessions = []
|
||||
self.loadErrorText = "Try again after the gateway reconnects."
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ item: CommandCenterTab.WorkItem) {
|
||||
switch item.route {
|
||||
case let .chat(sessionKey):
|
||||
self.appModel.openChat(sessionKey: sessionKey)
|
||||
self.openChat()
|
||||
case .settings:
|
||||
self.openSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
672
apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift
Normal file
672
apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift
Normal file
@@ -0,0 +1,672 @@
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Activity states") {
|
||||
IPadActivityStatesPreview()
|
||||
}
|
||||
|
||||
#Preview("Workboard states") {
|
||||
IPadWorkboardStatesPreview()
|
||||
}
|
||||
|
||||
#Preview("Skill Workshop states") {
|
||||
IPadSkillWorkshopStatesPreview()
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Skill Workshop iPad kanban lanes",
|
||||
traits: .fixedLayout(width: 1180, height: 820))
|
||||
{
|
||||
IPadSkillWorkshopKanbanPreview()
|
||||
}
|
||||
|
||||
#Preview("Workboard phone queue rows") {
|
||||
IPadWorkboardCompactRowsPreview()
|
||||
}
|
||||
|
||||
#Preview("Skill Workshop phone queue rows") {
|
||||
IPadSkillWorkshopCompactRowsPreview()
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Workboard phone landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
IPadSidebarTaskScreenPreviewHost {
|
||||
IPadWorkboardScreen(openChat: {}, openSettings: {})
|
||||
}
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Skill Workshop phone landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
IPadSidebarTaskScreenPreviewHost {
|
||||
IPadSkillWorkshopScreen(openSettings: {})
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadWorkboardCompactRowsPreview: View {
|
||||
private let statuses = ["todo", "ready", "running", "review", "blocked", "done"]
|
||||
private let cards = IPadWorkboardPreviewFixtures.cards
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.previewHeader
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Queue",
|
||||
value: "\(self.cards.count)",
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(Array(self.cards.enumerated()), id: \.element.id) { index, card in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
IPadWorkboardQueueRow(
|
||||
card: card,
|
||||
statuses: self.statuses,
|
||||
isBusy: card.id == "preview-running",
|
||||
inspect: {},
|
||||
openSession: {},
|
||||
move: { _ in },
|
||||
archive: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "tray",
|
||||
title: "No cards",
|
||||
detail: "Create a card or change the filter.",
|
||||
value: "empty",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.environment(\.horizontalSizeClass, .compact)
|
||||
.environment(\.verticalSizeClass, .regular)
|
||||
}
|
||||
|
||||
private var previewHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Phone queue")
|
||||
.font(OpenClawType.headline)
|
||||
Text("Tap for detail, swipe or long-press for card actions.")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadSkillWorkshopCompactRowsPreview: View {
|
||||
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.previewHeader
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Queue",
|
||||
value: "\(self.proposals.count)",
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(Array(self.proposals.enumerated()), id: \.element.id) { index, proposal in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
IPadSkillProposalRow(
|
||||
proposal: proposal,
|
||||
isSelected: proposal.id == "preview-pending",
|
||||
isBusy: proposal.id == "preview-held")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "hammer",
|
||||
title: "No proposals",
|
||||
detail: "New proposals will appear here when agents draft skills.",
|
||||
value: "empty",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.environment(\.horizontalSizeClass, .compact)
|
||||
.environment(\.verticalSizeClass, .regular)
|
||||
}
|
||||
|
||||
private var previewHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Phone proposals")
|
||||
.font(OpenClawType.headline)
|
||||
Text("Tap for detail, swipe or long-press for proposal actions.")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadSidebarTaskScreenPreviewHost<Content: View>: View {
|
||||
@State private var appModel = NodeAppModel()
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
self.content
|
||||
}
|
||||
.environment(self.appModel)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadActivityStatesPreview: View {
|
||||
private let connectedSessions = [
|
||||
CommandCenterTab.WorkItem(
|
||||
id: "preview-main",
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "Main",
|
||||
detail: "Updated just now",
|
||||
state: "active",
|
||||
trailing: "open",
|
||||
color: OpenClawBrand.ok,
|
||||
progress: nil,
|
||||
route: .chat("main")),
|
||||
CommandCenterTab.WorkItem(
|
||||
id: "preview-ipad-audit",
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "iPad audit",
|
||||
detail: "Updated 8m ago",
|
||||
state: "recent",
|
||||
trailing: "open",
|
||||
color: OpenClawBrand.accent,
|
||||
progress: nil,
|
||||
route: .chat("ipad-audit")),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader("Connected")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "tailscale.local:18789",
|
||||
gatewayValue: "online",
|
||||
gatewayColor: OpenClawBrand.ok,
|
||||
sessionRows: self.connectedSessions,
|
||||
tailRows: [])
|
||||
|
||||
self.previewHeader("Loading")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "Fetching recent activity from the gateway.",
|
||||
gatewayValue: "online",
|
||||
gatewayColor: OpenClawBrand.ok,
|
||||
sessionRows: [],
|
||||
tailRows: [
|
||||
ActivityPreviewRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading sessions",
|
||||
detail: "Fetching recent activity from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent),
|
||||
])
|
||||
|
||||
self.previewHeader("Empty")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "tailscale.local:18789",
|
||||
gatewayValue: "online",
|
||||
gatewayColor: OpenClawBrand.ok,
|
||||
sessionRows: [],
|
||||
tailRows: [
|
||||
ActivityPreviewRow(
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "No recent sessions",
|
||||
detail: "Start a chat and it will appear here.",
|
||||
value: "empty",
|
||||
color: .secondary),
|
||||
])
|
||||
|
||||
self.previewHeader("Error")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "No gateway connection",
|
||||
gatewayValue: "offline",
|
||||
gatewayColor: .secondary,
|
||||
sessionRows: [],
|
||||
tailRows: [
|
||||
ActivityPreviewRow(
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
title: "Sessions unavailable",
|
||||
detail: "Try again after the gateway reconnects.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn),
|
||||
])
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
private func activityCard(
|
||||
gatewayTitle: String,
|
||||
gatewayDetail: String,
|
||||
gatewayValue: String,
|
||||
gatewayColor: Color,
|
||||
sessionRows: [CommandCenterTab.WorkItem],
|
||||
tailRows: [ActivityPreviewRow]) -> some View
|
||||
{
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Recent activity",
|
||||
value: nil,
|
||||
actionTitle: "Refresh",
|
||||
action: {})
|
||||
ProStatusRow(
|
||||
icon: gatewayValue == "online" ? "network" : "wifi.slash",
|
||||
title: gatewayTitle,
|
||||
detail: gatewayDetail,
|
||||
value: gatewayValue,
|
||||
color: gatewayColor,
|
||||
actionTitle: gatewayValue == "online" ? nil : "Settings",
|
||||
action: {})
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "square.and.arrow.down",
|
||||
title: "Share intake",
|
||||
detail: "No share events yet.",
|
||||
value: "iPad",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(sessionRows) { row in
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: row.icon,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
value: row.state,
|
||||
color: row.color,
|
||||
actionTitle: "Open",
|
||||
action: {})
|
||||
}
|
||||
ForEach(tailRows) { row in
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: row.icon,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
value: row.value,
|
||||
color: row.color,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityPreviewRow: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let value: String
|
||||
let color: Color
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadWorkboardStatesPreview: View {
|
||||
private let statuses = ["todo", "running", "review"]
|
||||
private let connectedCards = IPadWorkboardPreviewFixtures.cards
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader("Connected")
|
||||
self.connectedBoard
|
||||
|
||||
self.previewHeader("Empty")
|
||||
IPadWorkboardKanbanColumn(
|
||||
status: "todo",
|
||||
cards: [],
|
||||
statuses: self.statuses,
|
||||
busyCardID: nil,
|
||||
openSession: { _ in },
|
||||
inspect: { _ in },
|
||||
move: { _, _ in },
|
||||
archive: { _ in })
|
||||
.frame(maxWidth: 320)
|
||||
|
||||
self.previewHeader("Loading")
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "arrow.clockwise",
|
||||
title: "Loading cards",
|
||||
detail: "Refreshing the workboard from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
|
||||
self.previewHeader("Error")
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Cards unavailable",
|
||||
detail: "Check the gateway connection, then refresh.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: "Retry",
|
||||
action: {})
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connectedBoard: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(self.statuses, id: \.self) { status in
|
||||
IPadWorkboardKanbanColumn(
|
||||
status: status,
|
||||
cards: self.connectedCards.filter { $0.status == status },
|
||||
statuses: self.statuses,
|
||||
busyCardID: nil,
|
||||
openSession: { _ in },
|
||||
inspect: { _ in },
|
||||
move: { _, _ in },
|
||||
archive: { _ in })
|
||||
.frame(width: 282)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
|
||||
private enum IPadWorkboardPreviewFixtures {
|
||||
static let cards = [
|
||||
IPadWorkboardCard(
|
||||
id: "preview-todo",
|
||||
title: "Prep iPad sidebar audit",
|
||||
notes: "Confirm portrait drawer behavior before device install.",
|
||||
status: "todo",
|
||||
priority: "normal",
|
||||
labels: ["iPad", "UI"],
|
||||
agentId: "main",
|
||||
sessionKey: nil,
|
||||
position: 0,
|
||||
updatedAt: nil,
|
||||
metadata: nil),
|
||||
IPadWorkboardCard(
|
||||
id: "preview-running",
|
||||
title: "Verify phone workboard queue",
|
||||
notes: "Single-list compact flow with detail sheet actions.",
|
||||
status: "running",
|
||||
priority: "high",
|
||||
labels: ["phone"],
|
||||
agentId: "main",
|
||||
sessionKey: "session-preview",
|
||||
position: 1,
|
||||
updatedAt: nil,
|
||||
metadata: nil),
|
||||
IPadWorkboardCard(
|
||||
id: "preview-review",
|
||||
title: "Review adaptive shell",
|
||||
notes: "Make sure shared destinations stay device-specific.",
|
||||
status: "review",
|
||||
priority: "normal",
|
||||
labels: ["shell"],
|
||||
agentId: "main",
|
||||
sessionKey: nil,
|
||||
position: 2,
|
||||
updatedAt: nil,
|
||||
metadata: nil),
|
||||
]
|
||||
}
|
||||
|
||||
private struct IPadSkillWorkshopStatesPreview: View {
|
||||
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader("Connected")
|
||||
self.queueCard(self.proposals, selectedID: "preview-pending", busyID: nil)
|
||||
|
||||
self.previewHeader("Loading")
|
||||
self.queueCard(self.proposals, selectedID: "preview-pending", busyID: "preview-pending")
|
||||
|
||||
self.previewHeader("Empty")
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "hammer",
|
||||
title: "No proposals",
|
||||
detail: "New proposals will appear here when agents draft skills.",
|
||||
value: "empty",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
|
||||
self.previewHeader("Offline / Error")
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "wifi.slash",
|
||||
title: "Workshop offline",
|
||||
detail: "Connect to the gateway to load Skill Workshop proposals.",
|
||||
value: "offline",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Proposal unavailable",
|
||||
detail: "Try again after the gateway reconnects.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func queueCard(
|
||||
_ proposals: [IPadSkillProposal],
|
||||
selectedID: String?,
|
||||
busyID: String?) -> some View
|
||||
{
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Queue",
|
||||
value: "\(proposals.count)",
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(Array(proposals.enumerated()), id: \.element.id) { index, proposal in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
IPadSkillProposalRow(
|
||||
proposal: proposal,
|
||||
isSelected: proposal.id == selectedID,
|
||||
isBusy: proposal.id == busyID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadSkillWorkshopKanbanPreview: View {
|
||||
private let lanes = IPadSkillWorkshopPreviewFixtures.kanbanStatuses
|
||||
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(self.lanes, id: \.self) { status in
|
||||
IPadSkillProposalKanbanColumn(
|
||||
status: status,
|
||||
proposals: self.proposals.filter { $0.status == status },
|
||||
selectedProposalID: "preview-pending",
|
||||
inspectingProposalID: "preview-needs-review",
|
||||
canApplyProposalMutations: true,
|
||||
busyAction: nil,
|
||||
select: { _ in },
|
||||
inspect: { _ in },
|
||||
apply: { _ in },
|
||||
reject: { _ in })
|
||||
.frame(width: 282)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 22)
|
||||
}
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .regular)
|
||||
}
|
||||
|
||||
private var previewHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("iPad kanban")
|
||||
.font(OpenClawType.headline)
|
||||
Text("Wide layout with populated, empty, held, and custom proposal lanes.")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private enum IPadSkillWorkshopPreviewFixtures {
|
||||
static let kanbanStatuses = [
|
||||
"pending",
|
||||
"quarantined",
|
||||
"stale",
|
||||
"applied",
|
||||
"rejected",
|
||||
"needs-review",
|
||||
"manual_QA",
|
||||
]
|
||||
|
||||
static let proposals = [
|
||||
Self.proposal(
|
||||
id: "preview-pending",
|
||||
status: "pending",
|
||||
title: "Add Tailscale gateway helper",
|
||||
description: "Drafts a helper skill for checking local Tailscale reachability before pairing.",
|
||||
minutesAgo: 9),
|
||||
Self.proposal(
|
||||
id: "preview-applied",
|
||||
status: "applied",
|
||||
title: "Summarize channel health",
|
||||
description: "Adds a lightweight status summary for channel clients and recent routing failures.",
|
||||
minutesAgo: 47),
|
||||
Self.proposal(
|
||||
id: "preview-held",
|
||||
status: "quarantined",
|
||||
title: "Desktop automation bridge",
|
||||
description: "Held for review because it requests broader file access than mobile should expose.",
|
||||
minutesAgo: 128),
|
||||
Self.proposal(
|
||||
id: "preview-needs-review",
|
||||
status: "needs-review",
|
||||
title: "Review pairing diagnostics",
|
||||
description: "Adds a diagnostic checklist before trusting a new gateway certificate.",
|
||||
minutesAgo: 32),
|
||||
Self.proposal(
|
||||
id: "preview-manual-qa",
|
||||
status: "manual_QA",
|
||||
title: "Manual QA runbook",
|
||||
description: "Generates a device checklist for iPhone portrait and iPad split layouts.",
|
||||
minutesAgo: 15),
|
||||
]
|
||||
|
||||
private static func proposal(
|
||||
id: String,
|
||||
status: String,
|
||||
title: String,
|
||||
description: String,
|
||||
minutesAgo: Int) -> IPadSkillProposal
|
||||
{
|
||||
let updatedAt = ISO8601DateFormatter().string(from: Date().addingTimeInterval(Double(-minutesAgo * 60)))
|
||||
return IPadSkillProposal(
|
||||
entry: IPadSkillProposalManifestEntry(
|
||||
id: id,
|
||||
kind: "skill",
|
||||
status: status,
|
||||
title: title,
|
||||
description: description,
|
||||
skillName: title,
|
||||
skillKey: id,
|
||||
createdAt: updatedAt,
|
||||
updatedAt: updatedAt,
|
||||
scanState: "complete"),
|
||||
previous: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
17
apps/ios/Sources/Design/IPadSidebarFeatureScreens.swift
Normal file
17
apps/ios/Sources/Design/IPadSidebarFeatureScreens.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
struct EmptyParams: Encodable {}
|
||||
|
||||
enum IPadSidebarGatewayError: Error {
|
||||
case offline
|
||||
case invalidPayload
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .offline:
|
||||
"Gateway offline."
|
||||
case .invalidPayload:
|
||||
"Could not encode request."
|
||||
}
|
||||
}
|
||||
}
|
||||
91
apps/ios/Sources/Design/IPadSidebarScreenChrome.swift
Normal file
91
apps/ios/Sources/Design/IPadSidebarScreenChrome.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IPadSidebarScreenChrome<Content: View>: View {
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let usesNativeNavigationChrome: Bool
|
||||
let gatewayAction: (() -> Void)?
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
usesNativeNavigationChrome: Bool = false,
|
||||
gatewayAction: (() -> Void)? = nil,
|
||||
@ViewBuilder content: () -> Content)
|
||||
{
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.usesNativeNavigationChrome = usesNativeNavigationChrome
|
||||
self.gatewayAction = gatewayAction
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
|
||||
if !self.usesNativeNavigationChrome {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.title,
|
||||
subtitle: self.subtitle,
|
||||
titleFont: self.isCompactHeight ? OpenClawType.headline : OpenClawType.title2SemiBold,
|
||||
subtitleLineLimit: self.isCompactHeight ? 1 : 2)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
} accessory: {
|
||||
self.gatewayPill
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
self.content
|
||||
}
|
||||
.padding(.vertical, self.isCompactHeight ? 10 : 18)
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.safeAreaPadding(.bottom, self.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle(self.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(self.usesNativeNavigationChrome ? .visible : .hidden, for: .navigationBar)
|
||||
.toolbar {
|
||||
if self.usesNativeNavigationChrome, let gatewayAction {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(action: gatewayAction) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
}
|
||||
.accessibilityLabel("Gateway settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isCompactHeight: Bool {
|
||||
self.verticalSizeClass == .compact
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonBorderShape(.capsule)
|
||||
.openClawGlassButton()
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomScrollInset: CGFloat {
|
||||
self.isCompactHeight ? 150 : OpenClawProMetric.bottomScrollInset
|
||||
}
|
||||
}
|
||||
1246
apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift
Normal file
1246
apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift
Normal file
File diff suppressed because it is too large
Load Diff
1507
apps/ios/Sources/Design/IPadWorkboardScreen.swift
Normal file
1507
apps/ios/Sources/Design/IPadWorkboardScreen.swift
Normal file
File diff suppressed because it is too large
Load Diff
89
apps/ios/Sources/Design/LicenseDocuments.swift
Normal file
89
apps/ios/Sources/Design/LicenseDocuments.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct LicenseDocument: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let filename: String
|
||||
let body: String
|
||||
}
|
||||
|
||||
enum LicenseDocumentLoader {
|
||||
static let directoryName = "Licenses"
|
||||
|
||||
static func bundledDocuments(bundle: Bundle = .main) -> [LicenseDocument] {
|
||||
guard let resourceURL = bundle.resourceURL else { return [] }
|
||||
return self.documents(in: resourceURL.appendingPathComponent(self.directoryName, isDirectory: true))
|
||||
}
|
||||
|
||||
static func documents(in directoryURL: URL) -> [LicenseDocument] {
|
||||
let fileManager = FileManager.default
|
||||
guard let urls = try? fileManager.contentsOfDirectory(
|
||||
at: directoryURL,
|
||||
includingPropertiesForKeys: [.isRegularFileKey],
|
||||
options: [.skipsHiddenFiles])
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
return urls.compactMap(self.document(from:)).sorted { lhs, rhs in
|
||||
let titleComparison = lhs.title.localizedCaseInsensitiveCompare(rhs.title)
|
||||
if titleComparison == .orderedSame {
|
||||
return lhs.filename.localizedCaseInsensitiveCompare(rhs.filename) == .orderedAscending
|
||||
}
|
||||
return titleComparison == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
static func title(from filename: String) -> String {
|
||||
let name = URL(fileURLWithPath: filename).deletingPathExtension().lastPathComponent
|
||||
let title = name
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.split(whereSeparator: \.isWhitespace)
|
||||
.joined(separator: " ")
|
||||
return title.isEmpty ? filename : title
|
||||
}
|
||||
|
||||
private static func document(from url: URL) -> LicenseDocument? {
|
||||
let filename = url.lastPathComponent
|
||||
guard !filename.hasPrefix("."),
|
||||
url.pathExtension.lowercased() == "txt"
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let values = try? url.resourceValues(forKeys: [.isRegularFileKey])
|
||||
guard values?.isRegularFile == true else { return nil }
|
||||
guard let body = try? String(contentsOf: url, encoding: .utf8),
|
||||
!body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return LicenseDocument(
|
||||
id: filename,
|
||||
title: self.title(from: filename),
|
||||
filename: filename,
|
||||
body: body)
|
||||
}
|
||||
}
|
||||
|
||||
struct LicenseDocumentDetailView: View {
|
||||
let document: LicenseDocument
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Text(verbatim: self.document.body)
|
||||
.font(OpenClawType.monoFootnote)
|
||||
.foregroundStyle(.primary)
|
||||
.textSelection(.enabled)
|
||||
.accessibilityIdentifier("licenses-detail-text")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(OpenClawProMetric.pagePadding)
|
||||
}
|
||||
.background(OpenClawProBackground())
|
||||
.navigationTitle(self.document.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
145
apps/ios/Sources/Design/OpenClawBrand.swift
Normal file
145
apps/ios/Sources/Design/OpenClawBrand.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
enum AppAppearancePreference: String, CaseIterable, Identifiable {
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
|
||||
static let storageKey = "appearance.preference"
|
||||
|
||||
static var launchArgumentPreference: AppAppearancePreference? {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-appearance") else {
|
||||
return nil
|
||||
}
|
||||
let valueIndex = arguments.index(after: flagIndex)
|
||||
guard arguments.indices.contains(valueIndex) else { return nil }
|
||||
return AppAppearancePreference(rawValue: arguments[valueIndex].lowercased())
|
||||
}
|
||||
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .system: "System"
|
||||
case .light: "Light"
|
||||
case .dark: "Dark"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .system: "circle.lefthalf.filled"
|
||||
case .light: "sun.max"
|
||||
case .dark: "moon.stars"
|
||||
}
|
||||
}
|
||||
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .system: nil
|
||||
case .light: .light
|
||||
case .dark: .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppAppearanceModel {
|
||||
private(set) var preference: AppAppearancePreference
|
||||
|
||||
init(userDefaults: UserDefaults = .standard) {
|
||||
let storedPreference = userDefaults.string(forKey: AppAppearancePreference.storageKey)
|
||||
.flatMap(AppAppearancePreference.init(rawValue:))
|
||||
self.preference = AppAppearancePreference.launchArgumentPreference ?? storedPreference ?? .system
|
||||
if AppAppearancePreference.launchArgumentPreference != nil {
|
||||
userDefaults.set(self.preference.rawValue, forKey: AppAppearancePreference.storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
func select(_ preference: AppAppearancePreference, userDefaults: UserDefaults = .standard) {
|
||||
guard self.preference != preference else { return }
|
||||
userDefaults.set(preference.rawValue, forKey: AppAppearancePreference.storageKey)
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
self.preference = preference
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OpenClawBrand {
|
||||
// Accent fills stay dark enough for white content; foreground accents adapt
|
||||
// separately so small labels retain 4.5:1 contrast on dark surfaces and tinted pills.
|
||||
static let uiAccent = adaptiveUIColor(light: (183, 56, 51), dark: (198, 62, 56))
|
||||
static let uiAccentForeground = adaptiveUIColor(light: (183, 56, 51), dark: (255, 107, 102))
|
||||
static let uiAccentHot = adaptiveUIColor(light: (204, 75, 69), dark: (232, 92, 86))
|
||||
static let uiAccentHotForeground = adaptiveUIColor(light: (166, 55, 50), dark: (255, 123, 115))
|
||||
static let uiAccentPressed = adaptiveUIColor(light: (148, 40, 36), dark: (148, 40, 36))
|
||||
static let uiTeal = adaptiveUIColor(light: (0, 196, 176), dark: (0, 196, 176))
|
||||
static let uiVoid = adaptiveUIColor(light: (246, 247, 249), dark: (11, 12, 17))
|
||||
static let uiObsidian = adaptiveUIColor(light: (255, 255, 255), dark: (19, 21, 28))
|
||||
static let uiTextPrimary = adaptiveUIColor(light: (11, 12, 17), dark: (242, 239, 232))
|
||||
static let uiTextSecondary = adaptiveUIColor(light: (90, 94, 110), dark: (168, 170, 191))
|
||||
static let uiOK = adaptiveUIColor(light: (19, 122, 62), dark: (48, 209, 88))
|
||||
static let uiWarn = adaptiveUIColor(light: (154, 87, 0), dark: (255, 214, 10))
|
||||
static let uiDanger = adaptiveUIColor(light: (185, 28, 28), dark: (252, 165, 165))
|
||||
static let uiInfo = adaptiveUIColor(light: (0, 91, 196), dark: (100, 168, 255))
|
||||
|
||||
static let accent = Color(uiColor: Self.uiAccent)
|
||||
static let accentForeground = Color(uiColor: Self.uiAccentForeground)
|
||||
static let accentHot = Color(uiColor: Self.uiAccentHot)
|
||||
static let accentHotForeground = Color(uiColor: Self.uiAccentHotForeground)
|
||||
static let accentPressed = Color(uiColor: Self.uiAccentPressed)
|
||||
static let teal = Color(uiColor: Self.uiTeal)
|
||||
static let void = Color(uiColor: Self.uiVoid)
|
||||
static let obsidian = Color(uiColor: Self.uiObsidian)
|
||||
static let textPrimary = Color(uiColor: Self.uiTextPrimary)
|
||||
static let textSecondary = Color(uiColor: Self.uiTextSecondary)
|
||||
static let danger = Color(uiColor: Self.uiDanger)
|
||||
static let ok = Color(uiColor: Self.uiOK)
|
||||
static let warn = Color(uiColor: Self.uiWarn)
|
||||
static let info = Color(uiColor: Self.uiInfo)
|
||||
static let graphite = void
|
||||
static let graphiteElevated = obsidian
|
||||
|
||||
static var sheetBackground: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
graphite,
|
||||
graphiteElevated.opacity(0.96),
|
||||
Color(uiColor: .systemBackground),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
private static func adaptiveUIColor(
|
||||
light: (red: CGFloat, green: CGFloat, blue: CGFloat),
|
||||
dark: (red: CGFloat, green: CGFloat, blue: CGFloat)) -> UIColor
|
||||
{
|
||||
UIColor { traits in
|
||||
let components = traits.userInterfaceStyle == .dark ? dark : light
|
||||
return UIColor(
|
||||
red: components.red / 255,
|
||||
green: components.green / 255,
|
||||
blue: components.blue / 255,
|
||||
alpha: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func openClawSheetChrome() -> some View {
|
||||
self
|
||||
.tint(OpenClawBrand.accent)
|
||||
.background {
|
||||
OpenClawBrand.sheetBackground
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
134
apps/ios/Sources/Design/OpenClawDocsScreen.swift
Normal file
134
apps/ios/Sources/Design/OpenClawDocsScreen.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OpenClawDocsScreen: View {
|
||||
private let docsURL = URL(string: "https://docs.openclaw.ai")!
|
||||
private let gatewayURL = URL(string: "https://docs.openclaw.ai/gateway")!
|
||||
private let pairingURL = URL(string: "https://docs.openclaw.ai/channels/pairing")!
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let usesNativeNavigationChrome: Bool
|
||||
let gatewayAction: (() -> Void)?
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
usesNativeNavigationChrome: Bool = false,
|
||||
gatewayAction: (() -> Void)? = nil)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.usesNativeNavigationChrome = usesNativeNavigationChrome
|
||||
self.gatewayAction = gatewayAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if !self.usesNativeNavigationChrome {
|
||||
self.headerCard
|
||||
}
|
||||
self.linkCard
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Docs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(self.usesNativeNavigationChrome ? .visible : .hidden, for: .navigationBar)
|
||||
.toolbar {
|
||||
if self.usesNativeNavigationChrome, let gatewayAction {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(action: gatewayAction) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.accessibilityLabel("Gateway settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Docs",
|
||||
subtitle: "Gateway setup, pairing, channels, and mobile node reference.",
|
||||
titleFont: OpenClawType.headline,
|
||||
subtitleFont: OpenClawType.caption)
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
ProIconBadge(systemName: "book", color: OpenClawBrand.accent)
|
||||
}
|
||||
} accessory: {
|
||||
self.gatewayPill
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonBorderShape(.capsule)
|
||||
.openClawGlassButton()
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
|
||||
private var linkCard: some View {
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
self.docsLinkRow(
|
||||
title: "Docs Home",
|
||||
detail: "Browse the current OpenClaw reference.",
|
||||
icon: "book",
|
||||
url: self.docsURL)
|
||||
Divider().padding(.leading, 58)
|
||||
self.docsLinkRow(
|
||||
title: "Gateway",
|
||||
detail: "Connection, auth, and diagnostics.",
|
||||
icon: "network",
|
||||
url: self.gatewayURL)
|
||||
Divider().padding(.leading, 58)
|
||||
self.docsLinkRow(
|
||||
title: "Pairing",
|
||||
detail: "Mobile setup codes, QR, and node approval.",
|
||||
icon: "qrcode",
|
||||
url: self.pairingURL)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func docsLinkRow(title: String, detail: String, icon: String, url: URL) -> some View {
|
||||
Link(destination: url) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(detail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(OpenClawType.captionBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
869
apps/ios/Sources/Design/OpenClawProComponents.swift
Normal file
869
apps/ios/Sources/Design/OpenClawProComponents.swift
Normal file
@@ -0,0 +1,869 @@
|
||||
import OpenClawChatUI
|
||||
import SwiftUI
|
||||
|
||||
enum OpenClawProMetric {
|
||||
static let pagePadding: CGFloat = 16
|
||||
static let cardRadius: CGFloat = 16
|
||||
static let controlRadius: CGFloat = 12
|
||||
static let compactControlSize: CGFloat = 36
|
||||
static let bottomScrollInset: CGFloat = 96
|
||||
}
|
||||
|
||||
enum OpenClawSpacing {
|
||||
static let space1: CGFloat = 4
|
||||
static let space2: CGFloat = 8
|
||||
static let space3: CGFloat = 12
|
||||
static let space4: CGFloat = 16
|
||||
static let space6: CGFloat = 24
|
||||
}
|
||||
|
||||
enum OpenClawRadius {
|
||||
static let xs: CGFloat = 8
|
||||
static let sm: CGFloat = 10
|
||||
static let md: CGFloat = 12
|
||||
static let lg: CGFloat = 16
|
||||
}
|
||||
|
||||
struct OpenClawProBackground: View {
|
||||
var body: some View {
|
||||
Color(uiColor: .systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
struct ProSectionHeader: View {
|
||||
let title: String
|
||||
var actionTitle: String?
|
||||
var action: (() -> Void)?
|
||||
var uppercase = true
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(self.title)
|
||||
.font(OpenClawType.footnoteMedium)
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(self.uppercase ? .uppercase : nil)
|
||||
Spacer()
|
||||
if let actionTitle {
|
||||
if let action {
|
||||
Button(action: action) {
|
||||
Text(actionTitle)
|
||||
.font(OpenClawType.footnoteMedium)
|
||||
}
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
} else {
|
||||
Text(actionTitle)
|
||||
.font(OpenClawType.footnoteMedium)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProCard<Content: View>: View {
|
||||
var tint: Color?
|
||||
var isProminent: Bool = false
|
||||
var padding: CGFloat = 12
|
||||
var radius: CGFloat = OpenClawProMetric.cardRadius
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
self.content
|
||||
.padding(self.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.proPanelSurface(
|
||||
tint: self.tint,
|
||||
radius: self.radius,
|
||||
isProminent: self.isProminent)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProPanelBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let radius: CGFloat
|
||||
let tint: Color?
|
||||
let isProminent: Bool
|
||||
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
shape
|
||||
.fill(self.fill)
|
||||
.overlay {
|
||||
shape.strokeBorder(self.borderStyle, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var fill: AnyShapeStyle {
|
||||
let color = self.isProminent ? UIColor.systemBackground : UIColor.secondarySystemGroupedBackground
|
||||
return AnyShapeStyle(Color(uiColor: color))
|
||||
}
|
||||
|
||||
private var borderStyle: AnyShapeStyle {
|
||||
if let tint {
|
||||
return AnyShapeStyle(tint.opacity(self.isProminent ? 0.18 : 0.10))
|
||||
}
|
||||
return AnyShapeStyle(Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.22 : 0.12))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProInsetSurfaceModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let tint: Color
|
||||
let radius: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
content.background {
|
||||
shape
|
||||
.fill(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.overlay {
|
||||
shape.strokeBorder(
|
||||
self.tint.opacity(self.colorScheme == .dark ? 0.18 : 0.10),
|
||||
lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OpenClawGlassButtonModifier: ViewModifier {
|
||||
let prominent: Bool
|
||||
let tint: Color?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
if self.prominent {
|
||||
content
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.buttonStyle(.glassProminent)
|
||||
.tint(self.tint ?? OpenClawBrand.accent)
|
||||
} else {
|
||||
content
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.buttonStyle(.glass)
|
||||
.tint(self.tint)
|
||||
}
|
||||
} else if self.prominent {
|
||||
content
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(self.tint ?? OpenClawBrand.accent)
|
||||
} else {
|
||||
content
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.buttonStyle(.bordered)
|
||||
.tint(self.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OpenClawTabBarBehaviorModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
content.tabBarMinimizeBehavior(.onScrollDown)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OpenClawGlassSurfaceModifier: ViewModifier {
|
||||
let radius: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
content.glassEffect(.regular, in: .rect(cornerRadius: self.radius))
|
||||
} else {
|
||||
content.background(
|
||||
.regularMaterial,
|
||||
in: RoundedRectangle(cornerRadius: self.radius, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func proPanelSurface(
|
||||
tint: Color? = nil,
|
||||
radius: CGFloat = OpenClawProMetric.cardRadius,
|
||||
isProminent: Bool = false) -> some View
|
||||
{
|
||||
modifier(ProPanelSurfaceModifier(
|
||||
tint: tint,
|
||||
radius: radius,
|
||||
isProminent: isProminent))
|
||||
}
|
||||
|
||||
func proInsetSurface(tint: Color, radius: CGFloat) -> some View {
|
||||
modifier(ProInsetSurfaceModifier(tint: tint, radius: radius))
|
||||
}
|
||||
|
||||
func openClawGlassButton(prominent: Bool = false, tint: Color? = nil) -> some View {
|
||||
modifier(OpenClawGlassButtonModifier(prominent: prominent, tint: tint))
|
||||
}
|
||||
|
||||
func openClawTabBarBehavior() -> some View {
|
||||
modifier(OpenClawTabBarBehaviorModifier())
|
||||
}
|
||||
|
||||
func openClawGlassSurface(radius: CGFloat = OpenClawProMetric.controlRadius) -> some View {
|
||||
modifier(OpenClawGlassSurfaceModifier(radius: radius))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProPanelSurfaceModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let tint: Color?
|
||||
let radius: CGFloat
|
||||
let isProminent: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background {
|
||||
ProPanelBackground(
|
||||
radius: self.radius,
|
||||
tint: self.tint,
|
||||
isProminent: self.isProminent)
|
||||
}
|
||||
.shadow(
|
||||
color: self.isProminent
|
||||
? (self.colorScheme == .dark ? .black.opacity(0.14) : .black.opacity(0.045))
|
||||
: .clear,
|
||||
radius: self.isProminent ? 5 : 0,
|
||||
y: self.isProminent ? 2 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProIconBadge: View {
|
||||
let systemName: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: self.systemName)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(self.color)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: OpenClawRadius.xs, style: .continuous)
|
||||
.fill(self.color.opacity(0.12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarHeaderAction {
|
||||
let systemName: String
|
||||
let accessibilityLabel: String
|
||||
let accessibilityIdentifier: String?
|
||||
let action: () -> Void
|
||||
|
||||
init(
|
||||
systemName: String,
|
||||
accessibilityLabel: String,
|
||||
accessibilityIdentifier: String? = nil,
|
||||
action: @escaping () -> Void)
|
||||
{
|
||||
self.systemName = systemName
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarRevealButton: View {
|
||||
let headerAction: OpenClawSidebarHeaderAction
|
||||
|
||||
init(action: OpenClawSidebarHeaderAction) {
|
||||
self.headerAction = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let button = Button(action: headerAction.action) {
|
||||
Image(systemName: self.headerAction.systemName)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.frame(
|
||||
width: OpenClawProMetric.compactControlSize,
|
||||
height: OpenClawProMetric.compactControlSize)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonBorderShape(.circle)
|
||||
.openClawGlassButton(tint: OpenClawBrand.accent)
|
||||
.accessibilityLabel(self.headerAction.accessibilityLabel)
|
||||
|
||||
if let accessibilityIdentifier = headerAction.accessibilityIdentifier {
|
||||
button.accessibilityIdentifier(accessibilityIdentifier)
|
||||
} else {
|
||||
button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarHeaderLeadingSlot: View {
|
||||
let action: OpenClawSidebarHeaderAction
|
||||
|
||||
var body: some View {
|
||||
OpenClawSidebarRevealButton(action: self.action)
|
||||
.frame(width: 44, height: 44, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawGlassControlGroup<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
GlassEffectContainer(spacing: 8) {
|
||||
self.content
|
||||
}
|
||||
} else {
|
||||
self.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OpenClawNoticeDetail {
|
||||
case accent(String)
|
||||
case requestID(String)
|
||||
}
|
||||
|
||||
struct OpenClawNoticeBanner: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
let ownerLabel: String
|
||||
let tint: Color
|
||||
var detail: OpenClawNoticeDetail?
|
||||
var primaryActionTitle: String?
|
||||
var onPrimaryAction: (() -> Void)?
|
||||
var secondaryActionTitle: String?
|
||||
var onSecondaryAction: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
ProCard(tint: self.tint, padding: 14) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.tint)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 0)
|
||||
Text(self.ownerLabel)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(self.message)
|
||||
.font(OpenClawType.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.detailView
|
||||
}
|
||||
}
|
||||
|
||||
if self.onPrimaryAction != nil || self.onSecondaryAction != nil {
|
||||
OpenClawGlassControlGroup {
|
||||
HStack(spacing: 10) {
|
||||
if let primaryActionTitle, let onPrimaryAction {
|
||||
Button(action: onPrimaryAction) {
|
||||
Text(primaryActionTitle)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.openClawGlassButton(prominent: true)
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let secondaryActionTitle, let onSecondaryAction {
|
||||
Button(action: onSecondaryAction) {
|
||||
Text(secondaryActionTitle)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.openClawGlassButton()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detailView: some View {
|
||||
if let detail {
|
||||
switch detail {
|
||||
case let .accent(value):
|
||||
Text(value)
|
||||
.font(OpenClawType.captionMedium)
|
||||
.foregroundStyle(self.tint)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
case let .requestID(value):
|
||||
Text("Request ID: \(value)")
|
||||
.font(OpenClawType.monoSmallMedium)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
var titleFont: Font = OpenClawType.title3SemiBold
|
||||
var subtitleFont: Font = OpenClawType.subhead
|
||||
var subtitleLineLimit: Int? = 2
|
||||
@ViewBuilder let leading: Leading
|
||||
@ViewBuilder let accessory: Accessory
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
titleFont: Font = OpenClawType.title3SemiBold,
|
||||
subtitleFont: Font = OpenClawType.subhead,
|
||||
subtitleLineLimit: Int? = 2,
|
||||
@ViewBuilder leading: () -> Leading,
|
||||
@ViewBuilder accessory: () -> Accessory)
|
||||
{
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.titleFont = titleFont
|
||||
self.subtitleFont = subtitleFont
|
||||
self.subtitleLineLimit = subtitleLineLimit
|
||||
self.leading = leading()
|
||||
self.accessory = accessory()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
self.horizontalLayout
|
||||
self.stackedLayout
|
||||
}
|
||||
}
|
||||
|
||||
private var horizontalLayout: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.leading
|
||||
|
||||
self.titleBlock
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
self.accessory
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
|
||||
private var stackedLayout: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.leading
|
||||
|
||||
self.titleBlock
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
self.accessory
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var titleBlock: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.title)
|
||||
.font(self.titleFont)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.86)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(self.subtitleFont)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(self.subtitleLineLimit)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared switch indicator replacing the 3 duplicated capsule toggles.
|
||||
/// Native Toggle only hits the switch edge on iOS 26; this full-width button approach
|
||||
/// gives the whole row a tap target.
|
||||
struct OpenClawToggleIndicator: View {
|
||||
let isOn: Bool
|
||||
|
||||
var body: some View {
|
||||
Capsule()
|
||||
.fill(self.isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
|
||||
.frame(width: 52, height: 32)
|
||||
.overlay(alignment: self.isOn ? .trailing : .leading) {
|
||||
Circle()
|
||||
.fill(Color.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.padding(2)
|
||||
.shadow(color: Color.black.opacity(0.14), radius: 1, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OpenClawStatusTone {
|
||||
case ok
|
||||
case warn
|
||||
case danger
|
||||
case info
|
||||
case accent
|
||||
case teal
|
||||
case muted
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .ok: OpenClawBrand.ok
|
||||
case .warn: OpenClawBrand.warn
|
||||
case .danger: OpenClawBrand.danger
|
||||
case .info: OpenClawBrand.info
|
||||
case .accent: OpenClawBrand.accent
|
||||
case .teal: OpenClawBrand.teal
|
||||
case .muted: OpenClawBrand.textSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawStatusBadge: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let label: String
|
||||
let tone: OpenClawStatusTone
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: OpenClawSpacing.space1 + 2) {
|
||||
Circle()
|
||||
.fill(self.tone.color)
|
||||
.frame(width: 7, height: 7)
|
||||
.shadow(color: self.tone.color.opacity(0.55), radius: 3)
|
||||
Text(self.label)
|
||||
.font(OpenClawType.caption2SemiBold)
|
||||
.foregroundStyle(self.tone.color)
|
||||
}
|
||||
.padding(.horizontal, OpenClawSpacing.space2)
|
||||
.padding(.vertical, 5)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.tone.color.opacity(self.colorScheme == .dark ? 0.14 : 0.10))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawPrimaryButtonStyle: ButtonStyle {
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(OpenClawType.headline)
|
||||
.foregroundStyle(self.isEnabled ? Color.white : OpenClawBrand.textSecondary)
|
||||
.frame(maxWidth: .infinity, minHeight: 48)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous)
|
||||
.fill(
|
||||
!self.isEnabled
|
||||
? Color(uiColor: .tertiarySystemFill)
|
||||
: configuration.isPressed
|
||||
? OpenClawBrand.accentPressed
|
||||
: OpenClawBrand.accent)
|
||||
}
|
||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||
.animation(.easeOut(duration: 0.15), value: self.isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSecondaryButtonStyle: ButtonStyle {
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(OpenClawType.headline)
|
||||
.foregroundStyle(self.isEnabled ? OpenClawBrand.textPrimary : OpenClawBrand.textSecondary.opacity(0.68))
|
||||
.frame(maxWidth: .infinity, minHeight: 48)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous)
|
||||
.fill(Color(uiColor: self.isEnabled ? .secondarySystemBackground : .tertiarySystemFill))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color(uiColor: .separator).opacity(self.isEnabled ? 0.35 : 0.20),
|
||||
lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.opacity(!self.isEnabled ? 0.74 : configuration.isPressed ? 0.82 : 1)
|
||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||
.animation(.easeOut(duration: 0.15), value: self.isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func openClawPrimaryButton() -> some View {
|
||||
self.buttonStyle(OpenClawPrimaryButtonStyle())
|
||||
}
|
||||
|
||||
func openClawSecondaryButton() -> some View {
|
||||
self.buttonStyle(OpenClawSecondaryButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
struct ProStatusDot: View {
|
||||
var color: Color
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(self.color)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProValuePill: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Text(self.value)
|
||||
.font(OpenClawType.footnoteSemiBold)
|
||||
.foregroundStyle(self.color)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.color.opacity(self.colorScheme == .dark ? 0.12 : 0.08))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawProMark: View {
|
||||
var size: CGFloat = 42
|
||||
var shadowRadius: CGFloat = 10
|
||||
|
||||
var body: some View {
|
||||
OpenClawMascotView()
|
||||
.frame(width: self.size, height: self.size)
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: self.shadowRadius, y: self.shadowRadius / 3)
|
||||
.accessibilityLabel("OpenClaw")
|
||||
}
|
||||
}
|
||||
|
||||
struct ProProgressBar: View {
|
||||
let progress: Double
|
||||
var color: Color = OpenClawBrand.accentHot
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let clamped = max(0, min(self.progress, 1))
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.primary.opacity(0.10))
|
||||
Capsule()
|
||||
.fill(self.color)
|
||||
.frame(width: proxy.size.width * clamped)
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawGatewayCompactPill: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
|
||||
var body: some View {
|
||||
OpenClawStatusBadge(label: self.title, tone: self.tone)
|
||||
.accessibilityLabel("Gateway \(self.title)")
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
"Online"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var tone: OpenClawStatusTone {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
.ok
|
||||
case .connecting:
|
||||
.accent
|
||||
case .error:
|
||||
.warn
|
||||
case .disconnected:
|
||||
.muted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProMetricTile: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
let value: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Image(systemName: self.icon)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(self.color)
|
||||
.frame(width: 24, height: 24)
|
||||
.background(self.color.opacity(self.colorScheme == .dark ? 0.18 : 0.10), in: Circle())
|
||||
Spacer(minLength: 4)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.value)
|
||||
.font(OpenClawType.headlineBold)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
Text(self.title)
|
||||
.font(OpenClawType.caption2Medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.proInsetSurface(tint: self.color, radius: OpenClawProMetric.controlRadius)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProMetric: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let value: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
struct ProMetricGrid: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
let metrics: [ProMetric]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible()), count: self.columnCount),
|
||||
spacing: 10)
|
||||
{
|
||||
ForEach(self.metrics) { metric in
|
||||
ProMetricTile(
|
||||
title: metric.title,
|
||||
value: metric.value,
|
||||
icon: metric.icon,
|
||||
color: metric.color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var columnCount: Int {
|
||||
guard self.horizontalSizeClass != .compact else { return 1 }
|
||||
return min(max(self.metrics.count, 1), 3)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProPanelHeader: View {
|
||||
let title: String
|
||||
var value: String?
|
||||
var actionTitle: String?
|
||||
var actionIcon: String?
|
||||
var actionAccessibilityLabel: String?
|
||||
var isActionDisabled = false
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(self.title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(OpenClawType.caption2Bold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.actionControl
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionControl: some View {
|
||||
if let action {
|
||||
if let actionIcon {
|
||||
Button(action: action) {
|
||||
Image(systemName: actionIcon)
|
||||
}
|
||||
.accessibilityLabel(self.actionAccessibilityLabel ?? actionTitle ?? self.title)
|
||||
.disabled(self.isActionDisabled)
|
||||
} else if let actionTitle {
|
||||
Button(action: action) {
|
||||
Text(actionTitle)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.disabled(self.isActionDisabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProStatusRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let value: String?
|
||||
let color: Color
|
||||
var actionTitle: String?
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
Text(self.detail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
if let value {
|
||||
ProValuePill(value: value, color: self.color)
|
||||
}
|
||||
if let actionTitle, let action {
|
||||
Button(action: action) {
|
||||
Text(actionTitle)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
447
apps/ios/Sources/Design/OpenClawTypography.swift
Normal file
447
apps/ios/Sources/Design/OpenClawTypography.swift
Normal file
@@ -0,0 +1,447 @@
|
||||
import CoreText
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum OpenClawType {
|
||||
// MARK: - Display — Red Hat Display
|
||||
|
||||
static var title1: Font {
|
||||
scaledDisplay(weight: Display.heavyTitle, size: 34, relativeTo: .largeTitle)
|
||||
}
|
||||
|
||||
static var title2: Font {
|
||||
scaledDisplay(weight: Display.heavyTitle, size: 28, relativeTo: .title1)
|
||||
}
|
||||
|
||||
static var title3: Font {
|
||||
scaledDisplay(weight: Display.opticalBold, size: 22, relativeTo: .title2)
|
||||
}
|
||||
|
||||
static var title3SemiBold: Font {
|
||||
scaledDisplay(weight: Display.opticalSemiBold, size: 22, relativeTo: .title2)
|
||||
}
|
||||
|
||||
static var headline: Font {
|
||||
scaledDisplay(weight: Display.opticalSemiBold, size: 17, relativeTo: .headline)
|
||||
}
|
||||
|
||||
static var headlineBold: Font {
|
||||
scaledDisplay(weight: Display.opticalBold, size: 17, relativeTo: .headline)
|
||||
}
|
||||
|
||||
// MARK: - Body — Inter
|
||||
|
||||
static var body: Font {
|
||||
scaledBody(weight: Body.regular, size: 17, relativeTo: .body)
|
||||
}
|
||||
|
||||
static var callout: Font {
|
||||
scaledBody(weight: Body.regular, size: 16, relativeTo: .callout)
|
||||
}
|
||||
|
||||
static var subhead: Font {
|
||||
scaledBody(weight: Body.regular, size: 15, relativeTo: .subheadline)
|
||||
}
|
||||
|
||||
static var subheadMedium: Font {
|
||||
scaledBody(weight: Body.medium, size: 15, relativeTo: .subheadline)
|
||||
}
|
||||
|
||||
static var subheadSemiBold: Font {
|
||||
scaledDisplay(weight: Display.opticalSemiBold, size: 15, relativeTo: .subheadline)
|
||||
}
|
||||
|
||||
static var subheadBold: Font {
|
||||
scaledDisplay(weight: Display.opticalBold, size: 15, relativeTo: .subheadline)
|
||||
}
|
||||
|
||||
static var footnote: Font {
|
||||
scaledBody(weight: Body.regular, size: 13, relativeTo: .footnote)
|
||||
}
|
||||
|
||||
static var footnoteMedium: Font {
|
||||
scaledBody(weight: Body.medium, size: 13, relativeTo: .footnote)
|
||||
}
|
||||
|
||||
static var footnoteSemiBold: Font {
|
||||
scaledBody(weight: Body.semiBold, size: 13, relativeTo: .footnote)
|
||||
}
|
||||
|
||||
static var caption: Font {
|
||||
scaledBody(weight: Body.regular, size: 12, relativeTo: .caption1)
|
||||
}
|
||||
|
||||
static var captionMedium: Font {
|
||||
scaledBody(weight: Body.medium, size: 12, relativeTo: .caption1)
|
||||
}
|
||||
|
||||
static var captionSemiBold: Font {
|
||||
scaledBody(weight: Body.semiBold, size: 12, relativeTo: .caption1)
|
||||
}
|
||||
|
||||
static var captionBold: Font {
|
||||
scaledBody(weight: Body.bold, size: 12, relativeTo: .caption1)
|
||||
}
|
||||
|
||||
static func avatar(size: CGFloat) -> Font {
|
||||
self.scaledBody(weight: Body.bold, size: size, relativeTo: .caption1)
|
||||
}
|
||||
|
||||
static var caption2: Font {
|
||||
scaledBody(weight: Body.regular, size: 11, relativeTo: .caption2)
|
||||
}
|
||||
|
||||
static var caption2Medium: Font {
|
||||
scaledBody(weight: Body.medium, size: 11, relativeTo: .caption2)
|
||||
}
|
||||
|
||||
static var caption2SemiBold: Font {
|
||||
scaledBody(weight: Body.semiBold, size: 11, relativeTo: .caption2)
|
||||
}
|
||||
|
||||
static var caption2Bold: Font {
|
||||
scaledDisplay(weight: Display.opticalBold, size: 11, relativeTo: .caption2)
|
||||
}
|
||||
|
||||
static var title2SemiBold: Font {
|
||||
scaledDisplay(weight: Display.opticalSemiBold, size: 28, relativeTo: .title1)
|
||||
}
|
||||
|
||||
// MARK: - Mono — JetBrains Mono
|
||||
|
||||
static var mono: Font {
|
||||
scaledMono(name: Mono.regular, size: 14, relativeTo: .body)
|
||||
}
|
||||
|
||||
static var monoSmall: Font {
|
||||
scaledMono(name: Mono.regular, size: 12, relativeTo: .caption1)
|
||||
}
|
||||
|
||||
static var monoSmallMedium: Font {
|
||||
scaledMono(name: Mono.medium, size: 12, relativeTo: .caption1)
|
||||
}
|
||||
|
||||
static var monoCaption2: Font {
|
||||
scaledMono(name: Mono.regular, size: 11, relativeTo: .caption2)
|
||||
}
|
||||
|
||||
static var monoFootnote: Font {
|
||||
scaledMono(name: Mono.regular, size: 13, relativeTo: .footnote)
|
||||
}
|
||||
|
||||
static var monoHeadline: Font {
|
||||
scaledMono(name: Mono.medium, size: 17, relativeTo: .headline)
|
||||
}
|
||||
|
||||
/// PostScript names for bundled fonts. Keep aligned with `UIAppFonts` in `project.yml`.
|
||||
static let registeredPostScriptNames: [String] = [
|
||||
Display.postScriptName,
|
||||
Body.postScriptName,
|
||||
Body.italicPostScriptName,
|
||||
Mono.regular,
|
||||
Mono.medium,
|
||||
Mono.semiBold,
|
||||
]
|
||||
|
||||
@MainActor
|
||||
static func installUIKitAppearance() {
|
||||
self.applyUIKitAppearance(self.makeUIKitAppearanceFonts())
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func refreshUIKitAppearance(in windows: [UIWindow]) {
|
||||
let fonts = self.makeUIKitAppearanceFonts()
|
||||
self.applyUIKitAppearance(fonts)
|
||||
for window in windows {
|
||||
self.applyUIKitTypography(fonts, to: window)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeUIKitAppearanceFonts() -> UIKitAppearanceFonts {
|
||||
UIKitAppearanceFonts(
|
||||
inlineNavigationTitleFont: self.scaledDisplayUIFont(
|
||||
weight: Display.opticalSemiBold,
|
||||
size: 17,
|
||||
relativeTo: .headline,
|
||||
maximumPointSize: 22),
|
||||
largeNavigationTitleFont: self.scaledDisplayUIFont(
|
||||
weight: Display.heavyTitle,
|
||||
size: 34,
|
||||
relativeTo: .largeTitle,
|
||||
maximumPointSize: 44),
|
||||
tabBarNormalFont: self.scaledBodyUIFont(
|
||||
weight: Body.medium,
|
||||
size: 11,
|
||||
relativeTo: .caption2,
|
||||
maximumPointSize: 13),
|
||||
tabBarSelectedFont: self.scaledBodyUIFont(
|
||||
weight: Body.semiBold,
|
||||
size: 11,
|
||||
relativeTo: .caption2,
|
||||
maximumPointSize: 13),
|
||||
segmentedNormalFont: self.scaledBodyUIFont(
|
||||
weight: Body.medium,
|
||||
size: 13,
|
||||
relativeTo: .footnote,
|
||||
maximumPointSize: 16),
|
||||
segmentedSelectedFont: self.scaledBodyUIFont(
|
||||
weight: Body.semiBold,
|
||||
size: 13,
|
||||
relativeTo: .footnote,
|
||||
maximumPointSize: 16),
|
||||
barButtonFont: self.scaledBodyUIFont(
|
||||
weight: Body.semiBold,
|
||||
size: 17,
|
||||
relativeTo: .body,
|
||||
maximumPointSize: 22),
|
||||
disabledBarButtonFont: self.scaledBodyUIFont(
|
||||
weight: Body.regular,
|
||||
size: 17,
|
||||
relativeTo: .body,
|
||||
maximumPointSize: 22),
|
||||
textInputFont: self.scaledBodyUIFont(
|
||||
weight: Body.regular,
|
||||
size: 17,
|
||||
relativeTo: .body,
|
||||
maximumPointSize: 22))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func applyUIKitAppearance(_ fonts: UIKitAppearanceFonts) {
|
||||
let navigationBar = UINavigationBar.appearance()
|
||||
self.applyNavigationBarTitleAttributes(fonts, to: navigationBar)
|
||||
|
||||
let tabBarItem = UITabBarItem.appearance()
|
||||
self.applyTabBarItemAttributes(fonts, to: tabBarItem)
|
||||
|
||||
let segmentedControl = UISegmentedControl.appearance()
|
||||
self.applySegmentedControlAttributes(fonts, to: segmentedControl)
|
||||
|
||||
let barButtonItem = UIBarButtonItem.appearance()
|
||||
self.applyBarButtonItemAttributes(fonts, to: barButtonItem)
|
||||
|
||||
UITextField.appearance().font = fonts.textInputFont
|
||||
UITextView.appearance().font = fonts.textInputFont
|
||||
UISearchTextField.appearance().font = fonts.textInputFont
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func applyUIKitTypography(_ fonts: UIKitAppearanceFonts, to view: UIView) {
|
||||
switch view {
|
||||
case let navigationBar as UINavigationBar:
|
||||
self.applyNavigationBarTitleAttributes(fonts, to: navigationBar)
|
||||
for item in navigationBar.items ?? [] {
|
||||
self.applyNavigationItemBarButtonAttributes(fonts, to: item)
|
||||
}
|
||||
if let topItem = navigationBar.topItem {
|
||||
self.applyNavigationItemBarButtonAttributes(fonts, to: topItem)
|
||||
}
|
||||
case let tabBar as UITabBar:
|
||||
tabBar.items?.forEach { self.applyTabBarItemAttributes(fonts, to: $0) }
|
||||
case let segmentedControl as UISegmentedControl:
|
||||
self.applySegmentedControlAttributes(fonts, to: segmentedControl)
|
||||
case let toolbar as UIToolbar:
|
||||
toolbar.items?.forEach { self.applyBarButtonItemAttributes(fonts, to: $0) }
|
||||
case let searchTextField as UISearchTextField:
|
||||
searchTextField.font = fonts.textInputFont
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
for subview in view.subviews {
|
||||
self.applyUIKitTypography(fonts, to: subview)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func applyNavigationBarTitleAttributes(
|
||||
_ fonts: UIKitAppearanceFonts,
|
||||
to navigationBar: UINavigationBar)
|
||||
{
|
||||
var titleAttributes = navigationBar.titleTextAttributes ?? [:]
|
||||
titleAttributes[.font] = fonts.inlineNavigationTitleFont
|
||||
navigationBar.titleTextAttributes = titleAttributes
|
||||
|
||||
var largeTitleAttributes = navigationBar.largeTitleTextAttributes ?? [:]
|
||||
largeTitleAttributes[.font] = fonts.largeNavigationTitleFont
|
||||
navigationBar.largeTitleTextAttributes = largeTitleAttributes
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func applyTabBarItemAttributes(_ fonts: UIKitAppearanceFonts, to tabBarItem: UITabBarItem) {
|
||||
tabBarItem.setTitleTextAttributes([.font: fonts.tabBarNormalFont], for: .normal)
|
||||
tabBarItem.setTitleTextAttributes([.font: fonts.tabBarSelectedFont], for: .selected)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func applySegmentedControlAttributes(
|
||||
_ fonts: UIKitAppearanceFonts,
|
||||
to segmentedControl: UISegmentedControl)
|
||||
{
|
||||
segmentedControl.setTitleTextAttributes([.font: fonts.segmentedNormalFont], for: .normal)
|
||||
segmentedControl.setTitleTextAttributes([.font: fonts.segmentedSelectedFont], for: .selected)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func applyBarButtonItemAttributes(_ fonts: UIKitAppearanceFonts, to barButtonItem: UIBarButtonItem) {
|
||||
barButtonItem.setTitleTextAttributes([.font: fonts.barButtonFont], for: .normal)
|
||||
barButtonItem.setTitleTextAttributes([.font: fonts.barButtonFont], for: .highlighted)
|
||||
barButtonItem.setTitleTextAttributes([.font: fonts.barButtonFont], for: .selected)
|
||||
barButtonItem.setTitleTextAttributes([.font: fonts.disabledBarButtonFont], for: .disabled)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func applyNavigationItemBarButtonAttributes(
|
||||
_ fonts: UIKitAppearanceFonts,
|
||||
to item: UINavigationItem)
|
||||
{
|
||||
let barButtonItems = [
|
||||
item.backBarButtonItem,
|
||||
item.leftBarButtonItem,
|
||||
item.rightBarButtonItem,
|
||||
] + (item.leftBarButtonItems ?? []) + (item.rightBarButtonItems ?? [])
|
||||
barButtonItems.compactMap(\.self).forEach { self.applyBarButtonItemAttributes(fonts, to: $0) }
|
||||
}
|
||||
|
||||
private struct UIKitAppearanceFonts {
|
||||
let inlineNavigationTitleFont: UIFont
|
||||
let largeNavigationTitleFont: UIFont
|
||||
let tabBarNormalFont: UIFont
|
||||
let tabBarSelectedFont: UIFont
|
||||
let segmentedNormalFont: UIFont
|
||||
let segmentedSelectedFont: UIFont
|
||||
let barButtonFont: UIFont
|
||||
let disabledBarButtonFont: UIFont
|
||||
let textInputFont: UIFont
|
||||
}
|
||||
|
||||
private enum Display {
|
||||
static let postScriptName = "RedHatDisplay-Regular"
|
||||
static let opticalSemiBold: CGFloat = 650
|
||||
static let opticalBold: CGFloat = 750
|
||||
static let heavyTitle: CGFloat = 800
|
||||
}
|
||||
|
||||
private enum Body {
|
||||
static let postScriptName = "Inter-Regular"
|
||||
static let italicPostScriptName = "Inter-Italic"
|
||||
static let regular: CGFloat = 400
|
||||
static let medium: CGFloat = 500
|
||||
static let semiBold: CGFloat = 600
|
||||
static let bold: CGFloat = 700
|
||||
}
|
||||
|
||||
private enum Mono {
|
||||
static let regular = "JetBrainsMono-Regular"
|
||||
static let medium = "JetBrainsMono-Medium"
|
||||
static let semiBold = "JetBrainsMono-SemiBold"
|
||||
}
|
||||
|
||||
private static let fontWeightAxis = NSNumber(value: 2_003_265_652) // "wght"
|
||||
private static let opticalSizeAxis = NSNumber(value: 1_869_640_570) // "opsz"
|
||||
|
||||
private static func scaledDisplay(
|
||||
weight: CGFloat,
|
||||
size: CGFloat,
|
||||
relativeTo textStyle: UIFont.TextStyle) -> Font
|
||||
{
|
||||
Font(
|
||||
self.scaledDisplayUIFont(
|
||||
weight: weight,
|
||||
size: size,
|
||||
relativeTo: textStyle))
|
||||
}
|
||||
|
||||
private static func scaledBody(
|
||||
weight: CGFloat,
|
||||
size: CGFloat,
|
||||
relativeTo textStyle: UIFont.TextStyle) -> Font
|
||||
{
|
||||
Font(
|
||||
self.scaledBodyUIFont(
|
||||
weight: weight,
|
||||
size: size,
|
||||
relativeTo: textStyle))
|
||||
}
|
||||
|
||||
private static func scaledMono(
|
||||
name: String,
|
||||
size: CGFloat,
|
||||
relativeTo textStyle: UIFont.TextStyle) -> Font
|
||||
{
|
||||
self.scaledFont(name: name, size: size, relativeTo: textStyle)
|
||||
}
|
||||
|
||||
private static func scaledDisplayUIFont(
|
||||
weight: CGFloat,
|
||||
size: CGFloat,
|
||||
relativeTo textStyle: UIFont.TextStyle,
|
||||
maximumPointSize: CGFloat? = nil) -> UIFont
|
||||
{
|
||||
self.scaledVariableUIFont(
|
||||
name: Display.postScriptName,
|
||||
size: size,
|
||||
relativeTo: textStyle,
|
||||
maximumPointSize: maximumPointSize,
|
||||
variations: [self.fontWeightAxis: weight])
|
||||
}
|
||||
|
||||
private static func scaledBodyUIFont(
|
||||
weight: CGFloat,
|
||||
size: CGFloat,
|
||||
relativeTo textStyle: UIFont.TextStyle,
|
||||
maximumPointSize: CGFloat? = nil) -> UIFont
|
||||
{
|
||||
self.scaledVariableUIFont(
|
||||
name: Body.postScriptName,
|
||||
size: size,
|
||||
relativeTo: textStyle,
|
||||
maximumPointSize: maximumPointSize,
|
||||
variations: [
|
||||
self.fontWeightAxis: weight,
|
||||
self.opticalSizeAxis: min(max(size, 14), 32),
|
||||
])
|
||||
}
|
||||
|
||||
private static func scaledVariableUIFont(
|
||||
name: String,
|
||||
size: CGFloat,
|
||||
relativeTo textStyle: UIFont.TextStyle,
|
||||
maximumPointSize: CGFloat? = nil,
|
||||
variations: [NSNumber: CGFloat]) -> UIFont
|
||||
{
|
||||
guard UIFont(name: name, size: size) != nil else {
|
||||
let fallback = UIFont.systemFont(ofSize: size)
|
||||
return self.scaledUIFont(fallback, relativeTo: textStyle, maximumPointSize: maximumPointSize)
|
||||
}
|
||||
|
||||
let descriptor = UIFontDescriptor(fontAttributes: [
|
||||
.name: name,
|
||||
kCTFontVariationAttribute as UIFontDescriptor.AttributeName: variations,
|
||||
])
|
||||
let base = UIFont(descriptor: descriptor, size: size)
|
||||
return self.scaledUIFont(base, relativeTo: textStyle, maximumPointSize: maximumPointSize)
|
||||
}
|
||||
|
||||
private static func scaledFont(
|
||||
name: String,
|
||||
size: CGFloat,
|
||||
relativeTo textStyle: UIFont.TextStyle) -> Font
|
||||
{
|
||||
let base = UIFont(name: name, size: size) ?? UIFont.systemFont(ofSize: size)
|
||||
let scaled = UIFontMetrics(forTextStyle: textStyle).scaledFont(for: base)
|
||||
return Font(scaled)
|
||||
}
|
||||
|
||||
private static func scaledUIFont(
|
||||
_ base: UIFont,
|
||||
relativeTo textStyle: UIFont.TextStyle,
|
||||
maximumPointSize: CGFloat?) -> UIFont
|
||||
{
|
||||
let metrics = UIFontMetrics(forTextStyle: textStyle)
|
||||
if let maximumPointSize {
|
||||
return metrics.scaledFont(for: base, maximumPointSize: maximumPointSize)
|
||||
}
|
||||
return metrics.scaledFont(for: base)
|
||||
}
|
||||
}
|
||||
337
apps/ios/Sources/Design/RootTabsPhoneControlHub.swift
Normal file
337
apps/ios/Sources/Design/RootTabsPhoneControlHub.swift
Normal file
@@ -0,0 +1,337 @@
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct RootTabsPhoneControlHub: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@State private var navigationPath: [RootTabs.SidebarDestination] = []
|
||||
@State private var didApplyInitialDestination = false
|
||||
@State private var handledNavigationRequestID = 0
|
||||
|
||||
let groups: [RootTabs.SidebarGroup]
|
||||
let initialDestination: RootTabs.SidebarDestination?
|
||||
let navigationRequest: RootTabs.PhoneControlNavigationRequest?
|
||||
let openRootDestination: (RootTabs.SidebarDestination) -> Void
|
||||
let openChatFromControlDetail: (RootTabs.SidebarDestination) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: self.$navigationPath) {
|
||||
List {
|
||||
Section {
|
||||
Button {
|
||||
self.openGatewayDetail()
|
||||
} label: {
|
||||
self.gatewayRow
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
ForEach(self.phoneGroups) { group in
|
||||
Section {
|
||||
ForEach(group.destinations) { destination in
|
||||
self.destinationRow(destination)
|
||||
}
|
||||
} header: {
|
||||
if let title = self.sectionTitle(for: group) {
|
||||
Text(title)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle("Control")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationDestination(for: RootTabs.SidebarDestination.self) { destination in
|
||||
self.detail(for: destination)
|
||||
}
|
||||
.onAppear {
|
||||
self.applyInitialDestinationIfNeeded()
|
||||
self.applyNavigationRequestIfNeeded()
|
||||
}
|
||||
.onChange(of: self.navigationRequest) { _, _ in
|
||||
self.applyNavigationRequestIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayRow: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: "antenna.radiowaves.left.and.right",
|
||||
color: self.gatewayStateColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Gateway")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(.primary)
|
||||
Text(self.sidebarActiveAgentTitle)
|
||||
.font(OpenClawType.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
HStack(spacing: 6) {
|
||||
ProStatusDot(color: self.gatewayStateColor)
|
||||
Text(self.gatewayStateText)
|
||||
.font(OpenClawType.footnoteSemiBold)
|
||||
.foregroundStyle(self.gatewayStateColor)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("Gateway \(self.gatewayStateText), \(self.sidebarActiveAgentTitle)")
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View {
|
||||
if self.opensRootTab(destination) {
|
||||
Button {
|
||||
self.openPhoneRootDestination(destination)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
self.rowLabel(destination)
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
NavigationLink(value: destination) {
|
||||
self.rowLabel(destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func rowLabel(_ destination: RootTabs.SidebarDestination) -> some View {
|
||||
Label {
|
||||
Text(destination.title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(.primary)
|
||||
} icon: {
|
||||
ProIconBadge(systemName: destination.systemImage, color: .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detail(for destination: RootTabs.SidebarDestination) -> some View {
|
||||
switch destination {
|
||||
case .chat, .talk, .agents:
|
||||
EmptyView()
|
||||
case .gateway:
|
||||
SettingsProTab(directRoute: .gateway)
|
||||
case .overview:
|
||||
CommandCenterTab(
|
||||
ownsNavigationStack: false,
|
||||
usesNativeNavigationChrome: true,
|
||||
headerTitle: "Overview",
|
||||
showsHeaderMark: false,
|
||||
openChat: { self.openChatFromControlDetail(.overview) },
|
||||
openSettings: { self.openGatewayDetail() },
|
||||
openSessions: { self.navigationPath.append(.sessions) })
|
||||
case .activity:
|
||||
IPadActivityScreen(
|
||||
usesNativeNavigationChrome: true,
|
||||
openChat: { self.openChatFromControlDetail(.activity) },
|
||||
openSettings: { self.openGatewayDetail() })
|
||||
case .workboard:
|
||||
IPadWorkboardScreen(
|
||||
usesNativeNavigationChrome: true,
|
||||
openChat: { self.openChatFromControlDetail(.workboard) },
|
||||
openSettings: { self.openGatewayDetail() })
|
||||
case .skillWorkshop:
|
||||
IPadSkillWorkshopScreen(
|
||||
usesNativeNavigationChrome: true,
|
||||
openSettings: { self.openGatewayDetail() })
|
||||
case .instances:
|
||||
AgentProTab(
|
||||
directRoute: .instances,
|
||||
headerTitle: "Instances",
|
||||
openSettings: { self.openGatewayDetail() })
|
||||
case .sessions:
|
||||
CommandSessionsScreen(
|
||||
usesNativeNavigationChrome: true,
|
||||
openChat: { self.openChatFromControlDetail(.sessions) })
|
||||
case .dreaming:
|
||||
AgentProTab(
|
||||
directRoute: .dreaming,
|
||||
headerTitle: "Dreaming",
|
||||
openSettings: { self.openGatewayDetail() })
|
||||
case .usage:
|
||||
AgentProTab(
|
||||
directRoute: .usage,
|
||||
headerTitle: "Usage",
|
||||
openSettings: { self.openGatewayDetail() })
|
||||
case .cron:
|
||||
AgentProTab(
|
||||
directRoute: .cron,
|
||||
headerTitle: "Cron Jobs",
|
||||
openSettings: { self.openGatewayDetail() })
|
||||
case .terminal:
|
||||
TerminalHubScreen(
|
||||
usesNativeNavigationChrome: true,
|
||||
gatewayAction: { self.openGatewayDetail() })
|
||||
case .docs:
|
||||
OpenClawDocsScreen(
|
||||
usesNativeNavigationChrome: true,
|
||||
gatewayAction: { self.openGatewayDetail() })
|
||||
case .settings:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
/// Gateway settings open as a pushed detail on this stack so Back returns
|
||||
/// to the hub screen the user came from, not the canonical Settings tab.
|
||||
private func openGatewayDetail() {
|
||||
self.navigationPath.append(.gateway)
|
||||
}
|
||||
|
||||
private func openPhoneRootDestination(_ destination: RootTabs.SidebarDestination) {
|
||||
self.navigationPath.removeAll()
|
||||
self.openRootDestination(destination)
|
||||
}
|
||||
|
||||
private func opensRootTab(_ destination: RootTabs.SidebarDestination) -> Bool {
|
||||
RootTabs.shouldOpenRootTabFromPhoneHub(destination)
|
||||
}
|
||||
|
||||
private var phoneGroups: [RootTabs.SidebarGroup] {
|
||||
self.groups.compactMap { group in
|
||||
let destinations = group.destinations.filter { !self.opensRootTab($0) }
|
||||
guard !destinations.isEmpty else { return nil }
|
||||
return RootTabs.SidebarGroup(title: group.title, destinations: destinations)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyInitialDestinationIfNeeded() {
|
||||
guard !self.didApplyInitialDestination else { return }
|
||||
self.didApplyInitialDestination = true
|
||||
guard let initialDestination, initialDestination != .overview else { return }
|
||||
self.applyDestination(initialDestination)
|
||||
}
|
||||
|
||||
private func applyNavigationRequestIfNeeded() {
|
||||
guard let navigationRequest, navigationRequest.id != self.handledNavigationRequestID else { return }
|
||||
self.handledNavigationRequestID = navigationRequest.id
|
||||
switch navigationRequest.target {
|
||||
case .root:
|
||||
self.navigationPath.removeAll()
|
||||
case let .detail(destination):
|
||||
self.applyDestination(destination)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyDestination(_ destination: RootTabs.SidebarDestination) {
|
||||
if self.opensRootTab(destination) {
|
||||
self.openPhoneRootDestination(destination)
|
||||
} else {
|
||||
self.navigationPath = [destination]
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarActiveAgentTitle: String {
|
||||
let selectedID = self.normalized(self.appModel.selectedAgentId) ?? self.resolveDefaultAgentID()
|
||||
if let agent = self.appModel.gatewayAgents.first(where: { $0.id == selectedID }) {
|
||||
return self.agentTitle(for: agent)
|
||||
}
|
||||
return self.normalized(self.appModel.activeAgentName) ?? "Default Agent"
|
||||
}
|
||||
|
||||
private var gatewayStateText: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected: "Online"
|
||||
case .connecting: "Connecting"
|
||||
case .error: "Attention"
|
||||
case .disconnected: "Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStateColor: Color {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
OpenClawBrand.ok
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionTitle(for group: RootTabs.SidebarGroup) -> String? {
|
||||
switch group.title.lowercased() {
|
||||
case "chat": "Communication"
|
||||
case "control": nil
|
||||
default: group.title.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDefaultAgentID() -> String {
|
||||
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
|
||||
}
|
||||
|
||||
private func agentTitle(for agent: AgentSummary) -> String {
|
||||
let name = self.normalized(agent.name) ?? agent.id
|
||||
return name == agent.id ? name : "\(name) (\(agent.id))"
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Phone control hub offline") {
|
||||
RootTabsPhoneControlHub.preview(appModel: NodeAppModel())
|
||||
}
|
||||
|
||||
#Preview("Phone control hub connected") {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.enterAppleReviewDemoMode()
|
||||
return RootTabsPhoneControlHub.preview(appModel: appModel)
|
||||
}
|
||||
|
||||
#Preview("Phone control hub connecting") {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayStatusText = "Connecting..."
|
||||
return RootTabsPhoneControlHub.preview(appModel: appModel)
|
||||
}
|
||||
|
||||
#Preview("Phone control hub gateway error") {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayStatusText = "Gateway error: connection refused"
|
||||
return RootTabsPhoneControlHub.preview(appModel: appModel)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Phone control hub landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPhoneControlHub.preview(appModel: NodeAppModel())
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
}
|
||||
|
||||
extension RootTabsPhoneControlHub {
|
||||
fileprivate static func preview(appModel: NodeAppModel) -> some View {
|
||||
RootTabsPhoneControlHub(
|
||||
groups: RootTabs.phoneControlGroups,
|
||||
initialDestination: nil,
|
||||
navigationRequest: nil,
|
||||
openRootDestination: { _ in },
|
||||
openChatFromControlDetail: { _ in })
|
||||
.environment(appModel)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
679
apps/ios/Sources/Design/SettingsChannelsDestination.swift
Normal file
679
apps/ios/Sources/Design/SettingsChannelsDestination.swift
Normal file
@@ -0,0 +1,679 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsChannelsDestination: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
let showsSummaryCard: Bool
|
||||
@State private var snapshot: ChannelsStatusResult?
|
||||
@State private var isLoading = false
|
||||
@State private var errorText: String?
|
||||
@State private var busyOperation: SettingsChannelOperation?
|
||||
|
||||
init(showsSummaryCard: Bool = true) {
|
||||
self.showsSummaryCard = showsSummaryCard
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if self.showsSummaryCard {
|
||||
self.summaryCard
|
||||
}
|
||||
self.channelsCard
|
||||
}
|
||||
.font(OpenClawType.body)
|
||||
.task(id: self.refreshID) {
|
||||
await self.loadChannels(force: false)
|
||||
}
|
||||
.refreshable {
|
||||
await self.loadChannels(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "point.3.connected.trianglepath.dotted", color: self.summaryColor)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Channels / Integrations")
|
||||
.font(OpenClawType.headline)
|
||||
Text(self.summaryDetail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.summaryValue, color: self.summaryColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var channelsCard: some View {
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Message Routing",
|
||||
value: self.headerValue,
|
||||
actionIcon: self.isLoading ? "hourglass" : "arrow.clockwise",
|
||||
actionAccessibilityLabel: "Refresh Channels",
|
||||
isActionDisabled: self.isLoading,
|
||||
action: {
|
||||
Task { await self.loadChannels(force: true) }
|
||||
})
|
||||
|
||||
if let errorText {
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Channel status unavailable",
|
||||
detail: errorText,
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn)
|
||||
} else if !self.canRead {
|
||||
ProStatusRow(
|
||||
icon: "wifi.slash",
|
||||
title: "Gateway offline",
|
||||
detail: "Connect to the gateway to load installed channels, accounts, and routing status.",
|
||||
value: "offline",
|
||||
color: .secondary)
|
||||
} else if self.isLoading, self.snapshot == nil {
|
||||
ProStatusRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading channels",
|
||||
detail: "Fetching installed channels, accounts, and routing status from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent)
|
||||
} else if self.channelEntries.isEmpty {
|
||||
ProStatusRow(
|
||||
icon: "tray",
|
||||
title: "No channel plugins reported",
|
||||
detail: "Install or enable channel plugins on the gateway, then refresh.",
|
||||
value: "empty",
|
||||
color: .secondary)
|
||||
} else {
|
||||
ForEach(Array(self.channelEntries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
SettingsChannelRow(
|
||||
entry: entry,
|
||||
canAdmin: self.canAdmin,
|
||||
busyOperation: self.busyOperation,
|
||||
start: { accountID in
|
||||
Task { await self.run(.start, channelID: entry.id, accountID: accountID) }
|
||||
},
|
||||
stop: { accountID in
|
||||
Task { await self.run(.stop, channelID: entry.id, accountID: accountID) }
|
||||
},
|
||||
logout: { accountID in
|
||||
Task { await self.run(.logout, channelID: entry.id, accountID: accountID) }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var refreshID: String {
|
||||
[
|
||||
self.canRead ? "connected" : "offline",
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
private var canRead: Bool {
|
||||
self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var canAdmin: Bool {
|
||||
self.appModel.hasOperatorAdminScope
|
||||
}
|
||||
|
||||
static func shouldEnableChannelOperation(canRead: Bool, hasOperatorAdminScope: Bool) -> Bool {
|
||||
canRead && hasOperatorAdminScope
|
||||
}
|
||||
|
||||
private var headerValue: String? {
|
||||
if self.isLoading { return "Loading" }
|
||||
guard self.canRead else { return "Offline" }
|
||||
return "\(self.channelEntries.count)"
|
||||
}
|
||||
|
||||
private var summaryDetail: String {
|
||||
guard self.canRead else {
|
||||
return "Connect to load channel integrations."
|
||||
}
|
||||
if let errorText {
|
||||
return errorText
|
||||
}
|
||||
return "Installed channel clients, account state, and message-routing readiness."
|
||||
}
|
||||
|
||||
private var summaryValue: String {
|
||||
guard self.canRead else { return "offline" }
|
||||
if self.isLoading { return "loading" }
|
||||
if self.errorText != nil { return "error" }
|
||||
let configured = self.channelEntries.count(where: { $0.configured })
|
||||
return "\(configured)/\(self.channelEntries.count)"
|
||||
}
|
||||
|
||||
private var summaryColor: Color {
|
||||
guard self.canRead else { return .secondary }
|
||||
if self.errorText != nil { return OpenClawBrand.warn }
|
||||
return self.channelEntries.contains(where: { $0.running || $0.connected }) ? OpenClawBrand.ok : OpenClawBrand
|
||||
.accent
|
||||
}
|
||||
|
||||
private var channelEntries: [SettingsChannelEntry] {
|
||||
guard let snapshot else { return [] }
|
||||
let ids = snapshot.channelorder.isEmpty ? Array(snapshot.channels.keys).sorted() : snapshot.channelorder
|
||||
return ids.map { self.entry(channelID: $0, snapshot: snapshot) }
|
||||
}
|
||||
|
||||
private func entry(channelID: String, snapshot: ChannelsStatusResult) -> SettingsChannelEntry {
|
||||
let summary = snapshot.channels[channelID]?.dictionaryValue ?? [:]
|
||||
let accounts = self.accounts(channelID: channelID, snapshot: snapshot)
|
||||
let configured = accounts.contains(where: \.configured) || summary["configured"]?.boolValue == true
|
||||
let running = accounts.contains(where: \.running)
|
||||
let connected = accounts.contains(where: \.connected)
|
||||
let linked = accounts.contains(where: \.linked)
|
||||
let label = snapshot.channellabels[channelID]?.stringValue ?? Self.fallbackLabel(channelID)
|
||||
let detail = snapshot.channeldetaillabels?[channelID]?.stringValue ?? Self.fallbackDetail(channelID)
|
||||
let systemImage = snapshot.channelsystemimages?[channelID]?.stringValue ?? Self.fallbackSystemImage(channelID)
|
||||
let lastActivity = accounts.compactMap(\.lastActivityMs).max()
|
||||
let lastError = accounts.compactMap(\.lastError).first ?? summary["lastError"]?.stringValue
|
||||
return SettingsChannelEntry(
|
||||
id: channelID,
|
||||
label: label,
|
||||
detail: detail,
|
||||
systemImage: systemImage,
|
||||
configured: configured,
|
||||
running: running,
|
||||
connected: connected,
|
||||
linked: linked,
|
||||
lastActivityText: lastActivity.map(Self.relativeTime),
|
||||
lastError: lastError,
|
||||
unavailableReason: configured ? nil : "Configure this channel on the gateway.",
|
||||
accounts: accounts)
|
||||
}
|
||||
|
||||
private func accounts(channelID: String, snapshot: ChannelsStatusResult) -> [SettingsChannelAccount] {
|
||||
let rawAccounts = snapshot.channelaccounts[channelID]?.arrayValue ?? []
|
||||
return rawAccounts.compactMap { raw in
|
||||
guard let dict = raw.dictionaryValue else { return nil }
|
||||
let accountID = dict["accountId"]?.stringValue ?? "default"
|
||||
let name = dict["name"]?.stringValue
|
||||
let lastActivity = [
|
||||
dict["lastInboundAt"]?.intValue,
|
||||
dict["lastOutboundAt"]?.intValue,
|
||||
dict["lastTransportActivityAt"]?.intValue,
|
||||
]
|
||||
.compactMap(\.self)
|
||||
.max()
|
||||
return SettingsChannelAccount(
|
||||
id: accountID,
|
||||
name: name,
|
||||
configured: dict["configured"]?.boolValue == true,
|
||||
enabled: dict["enabled"]?.boolValue != false,
|
||||
running: dict["running"]?.boolValue == true,
|
||||
connected: dict["connected"]?.boolValue == true,
|
||||
linked: dict["linked"]?.boolValue == true,
|
||||
healthState: dict["healthState"]?.stringValue,
|
||||
lastError: dict["lastError"]?.stringValue,
|
||||
lastActivityMs: lastActivity)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadChannels(force: Bool) async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.canRead else {
|
||||
self.snapshot = nil
|
||||
self.errorText = nil
|
||||
return
|
||||
}
|
||||
if self.isLoading { return }
|
||||
|
||||
self.isLoading = true
|
||||
self.errorText = nil
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let params = ChannelsStatusParams(probe: false, timeoutms: 10000, channel: nil)
|
||||
let data = try await self.request(method: "channels.status", params: params, timeoutSeconds: 12)
|
||||
self.snapshot = try JSONDecoder().decode(ChannelsStatusResult.self, from: data)
|
||||
} catch {
|
||||
if force || self.snapshot == nil {
|
||||
self.errorText = Self.message(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func run(_ kind: SettingsChannelOperation.Kind, channelID: String, accountID: String?) async {
|
||||
guard Self.shouldEnableChannelOperation(canRead: self.canRead, hasOperatorAdminScope: self.canAdmin),
|
||||
self.busyOperation == nil
|
||||
else {
|
||||
return
|
||||
}
|
||||
self.busyOperation = SettingsChannelOperation(kind: kind, channelID: channelID, accountID: accountID)
|
||||
self.errorText = nil
|
||||
defer { self.busyOperation = nil }
|
||||
|
||||
do {
|
||||
switch kind {
|
||||
case .start:
|
||||
let params = ChannelsStartParams(channel: channelID, accountid: accountID)
|
||||
_ = try await self.request(method: "channels.start", params: params, timeoutSeconds: 20)
|
||||
case .stop:
|
||||
let params = ChannelsStopParams(channel: channelID, accountid: accountID)
|
||||
_ = try await self.request(method: "channels.stop", params: params, timeoutSeconds: 20)
|
||||
case .logout:
|
||||
let params = ChannelsLogoutParams(channel: channelID, accountid: accountID)
|
||||
_ = try await self.request(method: "channels.logout", params: params, timeoutSeconds: 20)
|
||||
}
|
||||
await self.loadChannels(force: true)
|
||||
} catch {
|
||||
self.errorText = Self.message(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func request(method: String, params: some Encodable, timeoutSeconds: Int) async throws -> Data {
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SettingsChannelError.invalidPayload
|
||||
}
|
||||
return try await self.appModel.operatorSession.request(
|
||||
method: method,
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
static func fallbackLabel(_ id: String) -> String {
|
||||
if let metadata = self.fallbackMetadata[id.lowercased()] {
|
||||
return metadata.label
|
||||
}
|
||||
return id.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.split(separator: " ")
|
||||
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func fallbackDetail(_ id: String) -> String {
|
||||
self.fallbackMetadata[id.lowercased()]?.detail ?? "Channel integration"
|
||||
}
|
||||
|
||||
static func fallbackSystemImage(_ id: String) -> String {
|
||||
self.fallbackMetadata[id.lowercased()]?.systemImage ?? "bubble.left.and.text.bubble.right"
|
||||
}
|
||||
|
||||
private static let fallbackMetadata: [String: SettingsChannelFallbackMetadata] = [
|
||||
"clickclack": SettingsChannelFallbackMetadata(
|
||||
label: "ClickClack",
|
||||
detail: "Self-hosted chat bot routing.",
|
||||
systemImage: "bubble.left.and.bubble.right"),
|
||||
]
|
||||
|
||||
private static func relativeTime(_ milliseconds: Int) -> String {
|
||||
let age = max(0, Int(Date().timeIntervalSince1970 * 1000) - milliseconds)
|
||||
let minutes = age / 60000
|
||||
if minutes < 1 { return "now" }
|
||||
if minutes < 60 { return "\(minutes)m ago" }
|
||||
let hours = minutes / 60
|
||||
if hours < 24 { return "\(hours)h ago" }
|
||||
return "\(hours / 24)d ago"
|
||||
}
|
||||
|
||||
private static func message(for error: Error) -> String {
|
||||
if let channelError = error as? SettingsChannelError {
|
||||
return channelError.message
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelRow: View {
|
||||
let entry: SettingsChannelEntry
|
||||
let canAdmin: Bool
|
||||
let busyOperation: SettingsChannelOperation?
|
||||
let start: (String?) -> Void
|
||||
let stop: (String?) -> Void
|
||||
let logout: (String?) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.entry.systemImage, color: self.entry.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.entry.label)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(self.entry.detailText)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let lastError = self.entry.lastError {
|
||||
Text(lastError)
|
||||
.font(OpenClawType.caption2Medium)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.entry.statusValue, color: self.entry.color)
|
||||
}
|
||||
|
||||
if !self.entry.accounts.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(self.entry.accounts.enumerated()), id: \.element.id) { index, account in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 38)
|
||||
}
|
||||
self.accountRow(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func accountRow(_ account: SettingsChannelAccount) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: account.running || account.connected ? "checkmark.circle.fill" : "circle")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(account.color)
|
||||
.frame(width: 28, height: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(account.displayName)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
Text(account.detailText)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Menu {
|
||||
if account.running {
|
||||
Button {
|
||||
self.stop(account.id)
|
||||
} label: {
|
||||
Text("Stop")
|
||||
.font(OpenClawType.subhead)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
self.start(account.id)
|
||||
} label: {
|
||||
Text("Start")
|
||||
.font(OpenClawType.subhead)
|
||||
}
|
||||
.disabled(!account.configured || !account.enabled)
|
||||
}
|
||||
if account.linked {
|
||||
Button(role: .destructive) {
|
||||
self.logout(account.id)
|
||||
} label: {
|
||||
Text("Logout")
|
||||
.font(OpenClawType.subhead)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: self.actionMenuIcon(account))
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
.disabled(!self.canAdmin || self.isBusy(account))
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func actionMenuIcon(_ account: SettingsChannelAccount) -> String {
|
||||
if self.isBusy(account) {
|
||||
return "hourglass"
|
||||
}
|
||||
if !self.canAdmin {
|
||||
return "lock.shield"
|
||||
}
|
||||
return "ellipsis.circle"
|
||||
}
|
||||
|
||||
private func isBusy(_ account: SettingsChannelAccount) -> Bool {
|
||||
self.busyOperation?.channelID == self.entry.id && self.busyOperation?.accountID == account.id
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelEntry: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
let configured: Bool
|
||||
let running: Bool
|
||||
let connected: Bool
|
||||
let linked: Bool
|
||||
let lastActivityText: String?
|
||||
let lastError: String?
|
||||
let unavailableReason: String?
|
||||
let accounts: [SettingsChannelAccount]
|
||||
|
||||
var color: Color {
|
||||
if self.connected || self.running { return OpenClawBrand.ok }
|
||||
if self.lastError != nil { return OpenClawBrand.warn }
|
||||
return self.configured ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var statusValue: String {
|
||||
if self.connected { return "connected" }
|
||||
if self.running { return "running" }
|
||||
if self.linked { return "linked" }
|
||||
if self.configured { return "configured" }
|
||||
return "not set"
|
||||
}
|
||||
|
||||
var detailText: String {
|
||||
if let lastActivityText {
|
||||
return "\(self.detail) • active \(lastActivityText)"
|
||||
}
|
||||
if let unavailableReason {
|
||||
return unavailableReason
|
||||
}
|
||||
return self.detail
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelFallbackMetadata {
|
||||
let label: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
}
|
||||
|
||||
private struct SettingsChannelAccount: Identifiable {
|
||||
let id: String
|
||||
let name: String?
|
||||
let configured: Bool
|
||||
let enabled: Bool
|
||||
let running: Bool
|
||||
let connected: Bool
|
||||
let linked: Bool
|
||||
let healthState: String?
|
||||
let lastError: String?
|
||||
let lastActivityMs: Int?
|
||||
|
||||
var displayName: String {
|
||||
let trimmedName = self.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmedName.isEmpty ? self.id : "\(trimmedName) (\(self.id))"
|
||||
}
|
||||
|
||||
var detailText: String {
|
||||
let state = if self.connected {
|
||||
"connected"
|
||||
} else if self.running {
|
||||
"running"
|
||||
} else if self.linked {
|
||||
"linked"
|
||||
} else if self.configured {
|
||||
"configured"
|
||||
} else {
|
||||
"not configured"
|
||||
}
|
||||
let enabledText = self.enabled ? "enabled" : "disabled"
|
||||
if let healthState, !healthState.isEmpty {
|
||||
return "\(state), \(enabledText), \(healthState)"
|
||||
}
|
||||
if let lastError, !lastError.isEmpty {
|
||||
return "\(state), \(enabledText), error"
|
||||
}
|
||||
return "\(state), \(enabledText)"
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
if self.connected || self.running { return OpenClawBrand.ok }
|
||||
if self.lastError != nil { return OpenClawBrand.warn }
|
||||
return self.configured ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelOperation: Equatable {
|
||||
enum Kind {
|
||||
case start
|
||||
case stop
|
||||
case logout
|
||||
}
|
||||
|
||||
let kind: Kind
|
||||
let channelID: String
|
||||
let accountID: String?
|
||||
}
|
||||
|
||||
private enum SettingsChannelError: Error {
|
||||
case invalidPayload
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .invalidPayload:
|
||||
"Could not encode channel request."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Channels states") {
|
||||
SettingsChannelsStatesPreview()
|
||||
}
|
||||
|
||||
private struct SettingsChannelsStatesPreview: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.stateSection("Connected") {
|
||||
SettingsChannelRow(
|
||||
entry: Self.telegramEntry,
|
||||
canAdmin: true,
|
||||
busyOperation: nil,
|
||||
start: { _ in },
|
||||
stop: { _ in },
|
||||
logout: { _ in })
|
||||
}
|
||||
|
||||
self.stateSection("Loading") {
|
||||
ProPanelHeader(
|
||||
title: "Message Routing",
|
||||
value: "Loading",
|
||||
actionIcon: "hourglass",
|
||||
actionAccessibilityLabel: "Refresh Channels",
|
||||
isActionDisabled: true,
|
||||
action: {})
|
||||
ProStatusRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading channel status",
|
||||
detail: "Checking installed channel clients and account state.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent)
|
||||
}
|
||||
|
||||
self.stateSection("Empty") {
|
||||
ProPanelHeader(
|
||||
title: "Message Routing",
|
||||
value: "0",
|
||||
actionIcon: "arrow.clockwise",
|
||||
actionAccessibilityLabel: "Refresh Channels",
|
||||
action: {})
|
||||
ProStatusRow(
|
||||
icon: "tray",
|
||||
title: "No channel plugins reported",
|
||||
detail: "Install or enable channel plugins on the gateway, then refresh.",
|
||||
value: "empty",
|
||||
color: .secondary)
|
||||
}
|
||||
|
||||
self.stateSection("Error") {
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Channel status unavailable",
|
||||
detail: "Gateway returned an unexpected channel status response.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn)
|
||||
}
|
||||
|
||||
self.stateSection("Offline") {
|
||||
ProStatusRow(
|
||||
icon: "wifi.slash",
|
||||
title: "Gateway offline",
|
||||
detail: "Connect to the gateway to load installed channels, accounts, and routing status.",
|
||||
value: "offline",
|
||||
color: .secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stateSection(
|
||||
_ title: String,
|
||||
@ViewBuilder content: () -> some View) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let telegramEntry = SettingsChannelEntry(
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
detail: "Message routing client",
|
||||
systemImage: "paperplane",
|
||||
configured: true,
|
||||
running: true,
|
||||
connected: true,
|
||||
linked: true,
|
||||
lastActivityText: "4m ago",
|
||||
lastError: nil,
|
||||
unavailableReason: nil,
|
||||
accounts: [
|
||||
SettingsChannelAccount(
|
||||
id: "main",
|
||||
name: "OpenClaw Ops",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: true,
|
||||
connected: true,
|
||||
linked: true,
|
||||
healthState: "healthy",
|
||||
lastError: nil,
|
||||
lastActivityMs: nil),
|
||||
])
|
||||
}
|
||||
#endif
|
||||
338
apps/ios/Sources/Design/SettingsProTab.swift
Normal file
338
apps/ios/Sources/Design/SettingsProTab.swift
Normal file
@@ -0,0 +1,338 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(VoiceWakeManager.self) var voiceWake
|
||||
@Environment(GatewayConnectionController.self) var gatewayController
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@AppStorage("node.displayName") var displayName: String = "iOS Node"
|
||||
@AppStorage("node.instanceId") var instanceId: String = UUID().uuidString
|
||||
@AppStorage("camera.enabled") var cameraEnabled: Bool = true
|
||||
@AppStorage("location.enabledMode") var locationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@AppStorage("screen.preventSleep") var preventSleep: Bool = true
|
||||
@AppStorage("talk.enabled") var talkEnabled: Bool = false
|
||||
@AppStorage(TalkModeProviderSelection.storageKey) var talkProviderSelectionRaw: String =
|
||||
TalkModeProviderSelection.gatewayDefault.rawValue
|
||||
@AppStorage(TalkModeRealtimeVoiceSelection.storageKey) var talkRealtimeVoiceSelectionRaw: String = ""
|
||||
@AppStorage(TalkSpeechLocale.storageKey) var talkSpeechLocale: String = TalkSpeechLocale.automaticID
|
||||
@AppStorage("talk.button.enabled") var talkButtonEnabled: Bool = true
|
||||
@AppStorage("talk.background.enabled") var talkBackgroundEnabled: Bool = false
|
||||
@AppStorage(TalkDefaults.speakerphoneEnabledKey) var talkSpeakerphoneEnabled: Bool =
|
||||
TalkDefaults.speakerphoneEnabledByDefault
|
||||
@AppStorage(VoiceWakePreferences.enabledKey) var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("gateway.autoconnect") var gatewayAutoConnect: Bool = false
|
||||
@AppStorage("gateway.manual.enabled") var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") var manualGatewayHost: String = ""
|
||||
@AppStorage("gateway.manual.port") var manualGatewayPort: Int = 18789
|
||||
@AppStorage("gateway.manual.tls") var manualGatewayTLS: Bool = true
|
||||
@AppStorage("gateway.discovery.debugLogs") var discoveryDebugLogsEnabled: Bool = false
|
||||
@AppStorage("canvas.debugStatusEnabled") var canvasDebugStatusEnabled: Bool = false
|
||||
@AppStorage("gateway.setupCode") var setupCode: String = ""
|
||||
@AppStorage("gateway.onboardingComplete") var onboardingComplete: Bool = false
|
||||
@AppStorage("gateway.hasConnectedOnce") var hasConnectedOnce: Bool = false
|
||||
@AppStorage("onboarding.requestID") var onboardingRequestID: Int = 0
|
||||
@State var isReconnectingGateway = false
|
||||
@State var isRefreshingGateway = false
|
||||
@State var isChangingLocationMode = false
|
||||
@State var connectingGatewayID: String?
|
||||
@State var selectedAgentPickerId = ""
|
||||
@State var gatewayToken = ""
|
||||
@State var gatewayPassword = ""
|
||||
@State var manualGatewayPortText = ""
|
||||
@State var setupStatusText: String?
|
||||
@State var stagedGatewaySetupLink: GatewayConnectDeepLink?
|
||||
@State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
|
||||
@State var defaultShareInstruction = ""
|
||||
@State var showQRScanner = false
|
||||
@State var scannerError: String?
|
||||
@State var showResetOnboardingAlert = false
|
||||
@State var suppressCredentialPersist = false
|
||||
@State var locationStatusText: String?
|
||||
@State var locationPermissionSummary = LocationPermissionSummary(
|
||||
desiredMode: .off,
|
||||
locationServicesEnabled: true,
|
||||
authorizationStatus: .notDetermined,
|
||||
accuracyAuthorization: .fullAccuracy)
|
||||
@State var locationPermissionRefreshID = 0
|
||||
@State var previousLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@State var notificationStatus: SettingsNotificationStatus = .checking
|
||||
@State var isRequestingNotificationAuthorization = false
|
||||
@State var showNotificationRelayDisclosure = false
|
||||
@State var diagnosticsLastRunText = "Not run"
|
||||
@State var diagnosticsIssueCount: Int?
|
||||
@State var showTalkIssueDetails = false
|
||||
@State private var navigationPath: [SettingsRoute] = []
|
||||
let initialRoute: SettingsRoute?
|
||||
let directRoute: SettingsRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let ownsNavigationStack: Bool
|
||||
let navigateToRoute: ((SettingsRoute) -> Void)?
|
||||
let onRouteChange: ((SettingsRoute?) -> Void)?
|
||||
|
||||
init(
|
||||
initialRoute: SettingsRoute? = nil,
|
||||
directRoute: SettingsRoute? = nil,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
ownsNavigationStack: Bool = true,
|
||||
navigateToRoute: ((SettingsRoute) -> Void)? = nil,
|
||||
onRouteChange: ((SettingsRoute?) -> Void)? = nil)
|
||||
{
|
||||
self.initialRoute = initialRoute
|
||||
self.directRoute = directRoute
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.ownsNavigationStack = ownsNavigationStack
|
||||
self.navigateToRoute = navigateToRoute
|
||||
self.onRouteChange = onRouteChange
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
self.settingsModalPresentation(
|
||||
self.settingsLifecycle(
|
||||
self.settingsContent))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var settingsContent: some View {
|
||||
if let directRoute {
|
||||
self.destination(for: directRoute)
|
||||
} else {
|
||||
if self.ownsNavigationStack {
|
||||
self.settingsNavigationStack
|
||||
} else {
|
||||
self.settingsNavigationContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var settingsNavigationStack: some View {
|
||||
NavigationStack(path: self.$navigationPath) {
|
||||
self.settingsNavigationContent
|
||||
}
|
||||
}
|
||||
|
||||
private var settingsNavigationContent: some View {
|
||||
List {
|
||||
self.gatewaySection
|
||||
self.settingsListSection
|
||||
}
|
||||
.font(OpenClawType.body)
|
||||
.navigationTitle("Settings")
|
||||
.navigationDestination(for: SettingsRoute.self) { route in
|
||||
self.destination(for: route)
|
||||
}
|
||||
.toolbar {
|
||||
if let headerLeadingAction {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
OpenClawSidebarRevealButton(action: headerLeadingAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func settingsLifecycle(_ content: some View) -> some View {
|
||||
content
|
||||
.task {
|
||||
self.previousLocationModeRaw = self.locationModeRaw
|
||||
self.syncSettingsState()
|
||||
self.refreshNotificationSettings()
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
self.applyInitialRouteIfNeeded()
|
||||
self.notifyRouteChange()
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
self.syncSettingsState()
|
||||
self.refreshNotificationSettings()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.locationModeRaw) { _, newValue in
|
||||
self.handleLocationModeChange(newValue)
|
||||
}
|
||||
.onChange(of: self.selectedAgentPickerId) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
|
||||
}
|
||||
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
|
||||
if newValue != self.selectedAgentPickerId {
|
||||
self.selectedAgentPickerId = newValue
|
||||
}
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
self.persistGatewayToken(newValue)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
self.persistGatewayPassword(newValue)
|
||||
}
|
||||
.onChange(of: self.setupCode) { _, newValue in
|
||||
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.stagedGatewaySetupLink = nil
|
||||
}
|
||||
}
|
||||
.onChange(of: self.defaultShareInstruction) { _, newValue in
|
||||
ShareToAgentSettings.saveDefaultInstruction(newValue)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
}
|
||||
.onChange(of: self.onboardingRequestID) { _, _ in
|
||||
// Root-owned resets leave Settings mounted behind onboarding.
|
||||
// Reload cleared credentials before the view can persist stale state.
|
||||
self.syncAfterOnboardingReset()
|
||||
}
|
||||
.onChange(of: self.navigationPath) { _, _ in
|
||||
self.notifyRouteChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func settingsModalPresentation(_ content: some View) -> some View {
|
||||
content
|
||||
.sheet(isPresented: self.$showTalkIssueDetails) {
|
||||
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
|
||||
TalkRuntimeIssueDetailsSheet(issue: issue)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedGatewayLink(link)
|
||||
},
|
||||
onSetupCode: { code in
|
||||
self.handleScannedSetupCode(code)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.setupStatusText = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.font(OpenClawType.body)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
self.showQRScanner = false
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showNotificationRelayDisclosure) {
|
||||
HostedPushRelayDisclosureSheet(
|
||||
message: self.notificationRelayDisclosureMessage,
|
||||
onContinue: self.requestNotificationAuthorizationFromSettings)
|
||||
}
|
||||
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
||||
Button(role: .destructive) {
|
||||
self.resetOnboarding()
|
||||
} label: {
|
||||
Text("Reset")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
Button(role: .cancel) {} label: {
|
||||
Text("Cancel")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
} message: {
|
||||
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
|
||||
.font(OpenClawType.subhead)
|
||||
}
|
||||
.alert(
|
||||
"QR Scanner Unavailable",
|
||||
isPresented: Binding(
|
||||
get: { self.scannerError != nil },
|
||||
set: { if !$0 { self.scannerError = nil } }))
|
||||
{
|
||||
Button(role: .cancel) {} label: {
|
||||
Text("OK")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
.font(OpenClawType.subhead)
|
||||
}
|
||||
}
|
||||
|
||||
func openNotificationsRouteFromApprovals() {
|
||||
guard self.directRoute == nil else { return }
|
||||
if !self.ownsNavigationStack, let navigateToRoute {
|
||||
navigateToRoute(.notifications)
|
||||
return
|
||||
}
|
||||
// Push, don't replace: Back from Notifications must return to the
|
||||
// Approvals screen the user came from, not reset to the Settings root.
|
||||
self.navigationPath.append(.notifications)
|
||||
}
|
||||
|
||||
private func applyInitialRouteIfNeeded() {
|
||||
guard self.directRoute == nil else { return }
|
||||
guard let initialRoute else { return }
|
||||
guard self.navigationPath != [initialRoute] else { return }
|
||||
self.navigationPath = [initialRoute]
|
||||
}
|
||||
|
||||
private func notifyRouteChange() {
|
||||
if let directRoute {
|
||||
self.onRouteChange?(directRoute)
|
||||
return
|
||||
}
|
||||
self.onRouteChange?(self.navigationPath.last)
|
||||
}
|
||||
}
|
||||
|
||||
struct HostedPushRelayDisclosureSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let message: String
|
||||
let onContinue: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Image(systemName: "network")
|
||||
.font(OpenClawType.title2SemiBold)
|
||||
.foregroundStyle(OpenClawBrand.accentForeground)
|
||||
Text("Enable OpenClaw Hosted Push Relay?")
|
||||
.font(OpenClawType.title3SemiBold)
|
||||
Text(self.message)
|
||||
.font(OpenClawType.body)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
Button {
|
||||
self.dismiss()
|
||||
self.onContinue()
|
||||
} label: {
|
||||
Text("Continue")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button(role: .cancel) {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Text("Not Now")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.tint(OpenClawBrand.accent)
|
||||
.padding(24)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
854
apps/ios/Sources/Design/SettingsProTabActions.swift
Normal file
854
apps/ios/Sources/Design/SettingsProTabActions.swift
Normal file
@@ -0,0 +1,854 @@
|
||||
import CoreLocation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
extension SettingsProTab {
|
||||
func detailStatusCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
SettingsIcon(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(OpenClawType.headline)
|
||||
Text(detail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(OpenClawType.subheadMedium)
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var diagnosticChecksCard: some View {
|
||||
Section("Checks") {
|
||||
self.diagnosticCheckRow(
|
||||
icon: "stethoscope",
|
||||
title: "Last Run",
|
||||
detail: self.diagnosticsLastRunText,
|
||||
value: self.diagnosticsRunValue,
|
||||
color: self.diagnosticsRunColor)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "Gateway Link",
|
||||
detail: self.gatewayStatusDetail,
|
||||
value: self.gatewayStatusValue,
|
||||
color: self.gatewayStatusColor)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "dot.radiowaves.left.and.right",
|
||||
title: "Discovery",
|
||||
detail: self.gatewayController.discoveryStatusText,
|
||||
value: "\(self.gatewayController.gateways.count)",
|
||||
color: self.gatewayController.gateways.isEmpty ? .secondary : OpenClawBrand.accent)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "waveform",
|
||||
title: "Talk Config",
|
||||
detail: self.gatewayTalkConfigDetail,
|
||||
value: self.gatewayTalkConfigValue,
|
||||
color: self.gatewayTalkConfigColor)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "bell",
|
||||
title: "Notifications",
|
||||
detail: "Approval and event alert channel",
|
||||
value: self.notificationStatusText,
|
||||
color: self.notificationStatus.color)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "rectangle.on.rectangle",
|
||||
title: "Screen Capture",
|
||||
detail: "Live foreground capture state",
|
||||
value: self.appModel.screenRecordActive ? "live" : "idle",
|
||||
color: self.appModel.screenRecordActive ? OpenClawBrand.ok : .secondary)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "mic",
|
||||
title: "Voice Wake",
|
||||
detail: self.appModel.voiceWake.statusText,
|
||||
value: self.voiceWakeEnabled ? "on" : "off",
|
||||
color: self.voiceWakeEnabled ? OpenClawBrand.ok : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
func diagnosticCheckRow(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
HStack(spacing: 12) {
|
||||
SettingsIcon(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(detail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(OpenClawType.subhead)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
func detailListCard(@ViewBuilder content: () -> some View) -> some View {
|
||||
Section {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
func detailRow(_ label: String, value: String) -> some View {
|
||||
LabeledContent(label) {
|
||||
Text(value)
|
||||
.font(OpenClawType.subhead)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
|
||||
func reconnectGateway() async {
|
||||
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
|
||||
guard !self.isReconnectingGateway else { return }
|
||||
self.isReconnectingGateway = true
|
||||
defer { self.isReconnectingGateway = false }
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runDiagnostics() async {
|
||||
guard !self.isRefreshingGateway else { return }
|
||||
self.isRefreshingGateway = true
|
||||
defer { self.isRefreshingGateway = false }
|
||||
|
||||
if !self.appModel.isAppleReviewDemoModeEnabled {
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
self.gatewayController.restartDiscovery()
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
}
|
||||
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
self.applyNotificationStatus(notificationSettings.authorizationStatus)
|
||||
self.registerForRemoteNotificationsIfEnrollmentReady()
|
||||
|
||||
let issueCount = SettingsDiagnostics.issueCount(
|
||||
gatewayConnected: self.gatewayDiagnosticConnected,
|
||||
discoveredGatewayCount: self.gatewayController.gateways.count,
|
||||
talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded,
|
||||
notificationsAllowed: self.notificationStatus == .allowed)
|
||||
self.diagnosticsIssueCount = issueCount
|
||||
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
|
||||
}
|
||||
|
||||
func syncSettingsState() {
|
||||
self.manualGatewayPortText = self.manualGatewayPort > 0 ? String(self.manualGatewayPort) : ""
|
||||
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
||||
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
|
||||
self.refreshLocationPermissionSummary()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedInstanceId.isEmpty else { return }
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
|
||||
func refreshLocationPermissionSummary(desiredMode modeOverride: OpenClawLocationMode? = nil) {
|
||||
let mode = modeOverride ?? OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||
let manager = CLLocationManager()
|
||||
self.locationPermissionRefreshID &+= 1
|
||||
let refreshID = self.locationPermissionRefreshID
|
||||
let currentSummary = self.locationPermissionSummary
|
||||
self.locationPermissionSummary = LocationPermissionSummary(
|
||||
desiredMode: mode,
|
||||
locationServicesEnabled: currentSummary.locationServicesEnabled,
|
||||
authorizationStatus: manager.authorizationStatus,
|
||||
accuracyAuthorization: manager.accuracyAuthorization)
|
||||
Task {
|
||||
let locationServicesEnabled = await Self.locationServicesEnabled()
|
||||
guard refreshID == self.locationPermissionRefreshID else { return }
|
||||
let latestManager = CLLocationManager()
|
||||
let latestMode = modeOverride ?? OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||
self.locationPermissionSummary = LocationPermissionSummary(
|
||||
desiredMode: latestMode,
|
||||
locationServicesEnabled: locationServicesEnabled,
|
||||
authorizationStatus: latestManager.authorizationStatus,
|
||||
accuracyAuthorization: latestManager.accuracyAuthorization)
|
||||
}
|
||||
}
|
||||
|
||||
private static func locationServicesEnabled() async -> Bool {
|
||||
await Task.detached(priority: .utility) {
|
||||
CLLocationManager.locationServicesEnabled()
|
||||
}.value
|
||||
}
|
||||
|
||||
func syncAfterOnboardingReset() {
|
||||
self.connectingGatewayID = nil
|
||||
self.setupStatusText = nil
|
||||
self.stagedGatewaySetupLink = nil
|
||||
self.pendingManualAuthOverride = nil
|
||||
self.syncSettingsState()
|
||||
}
|
||||
|
||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
self.connectingGatewayID = gateway.id
|
||||
defer { self.connectingGatewayID = nil }
|
||||
self.manualGatewayEnabled = false
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
|
||||
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
||||
if let err = await self.gatewayController.connectWithDiagnostics(gateway) {
|
||||
self.setupStatusText = err
|
||||
}
|
||||
}
|
||||
|
||||
func applySetupCodeAndConnect() async {
|
||||
self.setupStatusText = nil
|
||||
guard self.applySetupCode() else { return }
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard self.resolvedManualPort(host: host) != nil else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard await self.preflightGateway(host: host) else { return }
|
||||
self.setupStatusText = "Setup code applied. Connecting..."
|
||||
await self.connectManual()
|
||||
}
|
||||
|
||||
func applyPendingGatewaySetupLinkIfNeeded() {
|
||||
guard let link = self.appModel.consumePendingGatewaySetupLink() else { return }
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
self.stagedGatewaySetupLink = link
|
||||
let security = link.tls ? "TLS" : "plain"
|
||||
self.setupStatusText = "Setup link loaded for \(link.host):\(link.port) (\(security)). Tap Connect to apply."
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func applySetupCode() -> Bool {
|
||||
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stagedLink = self.stagedGatewaySetupLink
|
||||
guard !raw.isEmpty || stagedLink != nil else {
|
||||
self.setupStatusText = "Paste a setup code to continue."
|
||||
return false
|
||||
}
|
||||
|
||||
if AppleReviewDemoMode.isSetupCode(raw) {
|
||||
self.stagedGatewaySetupLink = nil
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = "Apple Review demo mode enabled."
|
||||
self.appModel.enterAppleReviewDemoMode()
|
||||
return false
|
||||
}
|
||||
|
||||
guard let link = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else {
|
||||
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
|
||||
return false
|
||||
}
|
||||
self.stagedGatewaySetupLink = nil
|
||||
self.applyGatewayLink(link)
|
||||
return true
|
||||
}
|
||||
|
||||
func applyGatewayLink(_ link: GatewayConnectDeepLink) {
|
||||
self.manualGatewayHost = link.host
|
||||
self.manualGatewayPort = link.port
|
||||
self.manualGatewayPortText = String(link.port)
|
||||
self.manualGatewayTLS = link.tls
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
|
||||
if setupAuth.hasBootstrapToken {
|
||||
GatewayOnboardingReset.prepareForBootstrapPairing(appModel: self.appModel, instanceId: instanceId)
|
||||
}
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: instanceId)
|
||||
}
|
||||
if setupAuth.shouldApplyTokenField {
|
||||
self.gatewayToken = setupAuth.token
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayToken(setupAuth.token, instanceId: instanceId)
|
||||
}
|
||||
}
|
||||
if setupAuth.shouldApplyPasswordField {
|
||||
self.gatewayPassword = setupAuth.password
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayPassword(setupAuth.password, instanceId: instanceId)
|
||||
}
|
||||
}
|
||||
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
|
||||
}
|
||||
|
||||
func openGatewayQRScanner() {
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectingGatewayID = nil
|
||||
self.setupStatusText = "Opening QR scanner..."
|
||||
self.showQRScanner = true
|
||||
}
|
||||
|
||||
func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) {
|
||||
self.showQRScanner = false
|
||||
self.setupCode = ""
|
||||
self.applyGatewayLink(link)
|
||||
self.setupStatusText = "QR loaded. Connecting to \(link.host):\(link.port)..."
|
||||
Task { await self.connectAfterScannedGatewayLink() }
|
||||
}
|
||||
|
||||
func handleScannedSetupCode(_ code: String) {
|
||||
guard AppleReviewDemoMode.isSetupCode(code) else { return }
|
||||
self.showQRScanner = false
|
||||
self.setupCode = ""
|
||||
self.stagedGatewaySetupLink = nil
|
||||
self.setupStatusText = "Apple Review demo mode enabled."
|
||||
self.appModel.enterAppleReviewDemoMode()
|
||||
}
|
||||
|
||||
func connectAfterScannedGatewayLink() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard self.resolvedManualPort(host: host) != nil else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard await self.preflightGateway(host: host) else { return }
|
||||
await self.connectManual()
|
||||
}
|
||||
|
||||
func connectManual() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.setupStatusText = "Failed: host required"
|
||||
return
|
||||
}
|
||||
guard self.manualPortIsValid else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
self.connectingGatewayID = "manual"
|
||||
self.manualGatewayEnabled = true
|
||||
defer { self.connectingGatewayID = nil }
|
||||
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
||||
token: self.gatewayToken,
|
||||
pendingOverride: self.pendingManualAuthOverride,
|
||||
password: self.gatewayPassword)
|
||||
self.pendingManualAuthOverride = nil
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualGatewayPort,
|
||||
useTLS: self.manualGatewayTLS,
|
||||
authOverride: authOverride)
|
||||
}
|
||||
|
||||
func preflightGateway(host: String) async -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
|
||||
self.setupStatusText = "Tailscale is off on this device. Turn it on, then try again."
|
||||
return false
|
||||
}
|
||||
self.gatewayController.requestLocalNetworkAccess(reason: "settings_preflight")
|
||||
return true
|
||||
}
|
||||
|
||||
func resetOnboarding() {
|
||||
self.connectingGatewayID = nil
|
||||
self.setupStatusText = nil
|
||||
self.setupCode = ""
|
||||
self.gatewayAutoConnect = false
|
||||
self.suppressCredentialPersist = true
|
||||
defer { self.suppressCredentialPersist = false }
|
||||
self.gatewayToken = ""
|
||||
self.gatewayPassword = ""
|
||||
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
|
||||
self.onboardingComplete = false
|
||||
self.hasConnectedOnce = false
|
||||
self.manualGatewayEnabled = false
|
||||
self.manualGatewayHost = ""
|
||||
self.onboardingRequestID += 1
|
||||
}
|
||||
|
||||
func handleLocationModeChange(_ newValue: String) {
|
||||
guard !self.isChangingLocationMode else { return }
|
||||
guard newValue != self.previousLocationModeRaw else { return }
|
||||
guard let mode = OpenClawLocationMode(rawValue: newValue) else { return }
|
||||
let previous = self.previousLocationModeRaw
|
||||
Task {
|
||||
await self.applyLocationMode(mode, rawValue: newValue, previous: previous)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func applyLocationMode(
|
||||
_ mode: OpenClawLocationMode,
|
||||
rawValue: String,
|
||||
previous: String) async
|
||||
{
|
||||
self.isChangingLocationMode = true
|
||||
self.locationStatusText = nil
|
||||
self.refreshLocationPermissionSummary(desiredMode: mode)
|
||||
defer { self.isChangingLocationMode = false }
|
||||
|
||||
if mode == .off {
|
||||
_ = await self.appModel.requestLocationPermissions(mode: mode)
|
||||
self.previousLocationModeRaw = rawValue
|
||||
self.refreshLocationPermissionSummary(desiredMode: mode)
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
return
|
||||
}
|
||||
|
||||
let granted = await self.appModel.requestLocationPermissions(mode: mode)
|
||||
self.refreshLocationPermissionSummary(desiredMode: mode)
|
||||
if granted {
|
||||
self.previousLocationModeRaw = rawValue
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
} else {
|
||||
self.locationModeRaw = previous
|
||||
self.previousLocationModeRaw = previous
|
||||
self.locationStatusText = "Location permission was not granted."
|
||||
self.refreshLocationPermissionSummary(
|
||||
desiredMode: OpenClawLocationMode(rawValue: previous) ?? .off)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshNotificationSettings() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
let status = settings.authorizationStatus
|
||||
Task { @MainActor in
|
||||
self.applyNotificationStatus(status)
|
||||
self.registerForRemoteNotificationsIfEnrollmentReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleNotificationAction() {
|
||||
if self.notificationStatus.shouldOpenNotificationSettings {
|
||||
self.openNotificationSettings()
|
||||
return
|
||||
}
|
||||
guard self.notificationStatus == .notSet else { return }
|
||||
|
||||
if PushBuildConfig.current.usesOpenClawHostedRelay {
|
||||
self.showNotificationRelayDisclosure = true
|
||||
return
|
||||
}
|
||||
self.requestNotificationAuthorizationFromSettings()
|
||||
}
|
||||
|
||||
func requestNotificationAuthorizationFromSettings() {
|
||||
guard !self.isRequestingNotificationAuthorization else { return }
|
||||
PushEnrollmentConsent.markDisclosureAccepted()
|
||||
self.isRequestingNotificationAuthorization = true
|
||||
Task {
|
||||
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
|
||||
.alert,
|
||||
.badge,
|
||||
.sound,
|
||||
])) ?? false
|
||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
await MainActor.run {
|
||||
self.isRequestingNotificationAuthorization = false
|
||||
self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus)
|
||||
guard granted else { return }
|
||||
self.registerForRemoteNotificationsIfEnrollmentReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func registerForRemoteNotificationsIfEnrollmentReady() {
|
||||
guard PushEnrollmentConsent.disclosureAccepted else { return }
|
||||
guard self.notificationStatus.allowsNotifications else { return }
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
|
||||
self.notificationStatus = SettingsNotificationStatus(status)
|
||||
}
|
||||
|
||||
func persistGatewayToken(_ value: String) {
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayToken(
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
instanceId: instanceId)
|
||||
}
|
||||
|
||||
func persistGatewayPassword(_ value: String) {
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
instanceId: instanceId)
|
||||
}
|
||||
|
||||
func openNotificationSettings() {
|
||||
guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
func title(for route: SettingsRoute) -> String {
|
||||
switch route {
|
||||
case .gateway: "Gateway"
|
||||
case .approvals: "Approvals"
|
||||
case .permissions: "Permissions"
|
||||
case .channels: "Channels"
|
||||
case .voice: "Voice & Talk"
|
||||
case .diagnostics: "Diagnostics"
|
||||
case .privacy: "Privacy"
|
||||
case .notifications: "Notifications"
|
||||
case .licenses: "Licenses"
|
||||
case .about: "About"
|
||||
}
|
||||
}
|
||||
|
||||
var manualPortBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.manualGatewayPortText },
|
||||
set: { newValue in
|
||||
let filtered = newValue.filter(\.isNumber)
|
||||
self.manualGatewayPortText = filtered
|
||||
self.manualGatewayPort = Int(filtered) ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
var manualPortIsValid: Bool {
|
||||
if self.manualGatewayPortText.isEmpty { return true }
|
||||
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
|
||||
}
|
||||
|
||||
func resolvedManualPort(host: String) -> Int? {
|
||||
if self.manualGatewayPort > 0 {
|
||||
return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil
|
||||
}
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if self.manualGatewayTLS, trimmed.lowercased().hasSuffix(".ts.net") {
|
||||
return 443
|
||||
}
|
||||
return 18789
|
||||
}
|
||||
|
||||
var setupStatusLine: String? {
|
||||
if let problem = self.appModel.lastGatewayProblem {
|
||||
return problem.message
|
||||
}
|
||||
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
|
||||
if let friendly = self.friendlyGatewayMessage(from: trimmedSetup) { return friendly }
|
||||
if self.isTransientSetupStatus(trimmedSetup),
|
||||
!gatewayStatus.isEmpty,
|
||||
gatewayStatus != "Offline"
|
||||
{
|
||||
return gatewayStatus
|
||||
}
|
||||
if !trimmedSetup.isEmpty { return trimmedSetup }
|
||||
if gatewayStatus.isEmpty || gatewayStatus == "Offline" { return nil }
|
||||
return gatewayStatus
|
||||
}
|
||||
|
||||
var canApplyGatewaySetup: Bool {
|
||||
!self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| self.stagedGatewaySetupLink != nil
|
||||
}
|
||||
|
||||
var tailnetWarningText: String? {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty, Self.isTailnetHostOrIP(host), !Self.hasTailnetIPv4() else { return nil }
|
||||
return "This gateway is on your tailnet. Turn on Tailscale on this device, then tap Connect."
|
||||
}
|
||||
|
||||
func friendlyGatewayMessage(from raw: String) -> String? {
|
||||
let lower = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if lower.contains("pairing required") {
|
||||
return "Pairing required. Run /pair approve in your OpenClaw chat, then connect again."
|
||||
}
|
||||
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
|
||||
return "Secure handshake failed. Check Tailscale, then connect again."
|
||||
}
|
||||
if lower.contains("tls fingerprint verification timed out")
|
||||
|| lower.contains("no tls endpoint detected")
|
||||
{
|
||||
return raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if lower.contains("timed out") {
|
||||
return "Connection timed out. Make sure Tailscale is connected, then try again."
|
||||
}
|
||||
if lower.contains("unauthorized role") {
|
||||
return "Connected, but some controls are restricted for nodes. This is expected."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isTransientSetupStatus(_ raw: String) -> Bool {
|
||||
let lower = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return lower == "setup code applied. connecting..."
|
||||
|| lower.hasPrefix("qr loaded. connecting to ")
|
||||
|| lower == "checking gateway reachability..."
|
||||
}
|
||||
|
||||
var shouldShowRealtimeVoicePicker: Bool {
|
||||
let providerSelection = TalkModeProviderSelection.resolved(self.talkProviderSelectionRaw)
|
||||
return providerSelection == .openAIRealtime || self.appModel.talkMode.gatewayTalkUsesRealtime
|
||||
}
|
||||
|
||||
var talkProviderSelectionBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.talkProviderSelectionRaw },
|
||||
set: { newValue in
|
||||
let selection = TalkModeProviderSelection.resolved(newValue)
|
||||
self.talkProviderSelectionRaw = selection.rawValue
|
||||
self.appModel.setTalkProviderSelection(selection.rawValue)
|
||||
})
|
||||
}
|
||||
|
||||
var talkRealtimeVoiceSelectionBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.talkRealtimeVoiceSelectionRaw },
|
||||
set: { newValue in
|
||||
let voice = TalkModeRealtimeVoiceSelection.resolvedOverride(newValue) ?? ""
|
||||
self.talkRealtimeVoiceSelectionRaw = voice
|
||||
self.appModel.setTalkRealtimeVoiceSelection(voice)
|
||||
})
|
||||
}
|
||||
|
||||
var talkSpeakerphoneBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { self.talkSpeakerphoneEnabled },
|
||||
set: { newValue in
|
||||
self.talkSpeakerphoneEnabled = newValue
|
||||
self.appModel.setTalkSpeakerphoneEnabled(newValue)
|
||||
})
|
||||
}
|
||||
|
||||
var talkApiKeyStatus: String {
|
||||
guard self.appModel.talkMode.gatewayTalkConfigLoaded else { return "Not loaded" }
|
||||
return self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured"
|
||||
}
|
||||
|
||||
var gatewayTalkActiveVoiceDetail: String {
|
||||
let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if title.isEmpty { return "Not active" }
|
||||
if subtitle.isEmpty { return title }
|
||||
return "\(title) • \(subtitle)"
|
||||
}
|
||||
|
||||
var gatewayTalkLastIssueDetail: String? {
|
||||
let detail = (self.appModel.talkMode.gatewayTalkLastIssueText ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return detail.isEmpty ? nil : detail
|
||||
}
|
||||
|
||||
func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
||||
var lines: [String] = []
|
||||
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
||||
let gw = gateway.gatewayPort.map(String.init)
|
||||
let canvas = gateway.canvasPort.map(String.init)
|
||||
if gw != nil || canvas != nil {
|
||||
lines.append("Ports: gateway \(gw ?? "-") / canvas \(canvas ?? "-")")
|
||||
}
|
||||
return lines.isEmpty ? [gateway.debugID] : lines
|
||||
}
|
||||
|
||||
var gatewayConnected: Bool {
|
||||
!self.appModel.isAppleReviewDemoModeEnabled &&
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
var gatewayStatusDetail: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "Apple Review demo mode" }
|
||||
return self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText
|
||||
}
|
||||
|
||||
var gatewayStatusValue: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.gatewayConnected ? "online" : "offline"
|
||||
}
|
||||
|
||||
var gatewayStatusColor: Color {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return OpenClawBrand.accent }
|
||||
return self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
||||
}
|
||||
|
||||
var gatewayDiagnosticConnected: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.gatewayConnected
|
||||
}
|
||||
|
||||
var gatewayDiagnosticTalkConfigLoaded: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.talkMode.gatewayTalkConfigLoaded
|
||||
}
|
||||
|
||||
var approvalEmptyDetail: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled {
|
||||
return "Live gateway requests are disabled in demo mode."
|
||||
}
|
||||
if self.notificationsNeedAttention {
|
||||
return "Foreground approvals still appear while OpenClaw is connected."
|
||||
}
|
||||
return self.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway."
|
||||
}
|
||||
|
||||
var gatewayTalkConfigDetail: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "Demo mode only" }
|
||||
return self.appModel.talkMode.gatewayTalkTransportLabel
|
||||
}
|
||||
|
||||
var gatewayTalkConfigValue: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing"
|
||||
}
|
||||
|
||||
var gatewayTalkConfigColor: Color {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return .secondary }
|
||||
return self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary
|
||||
}
|
||||
|
||||
var gatewayAddress: String {
|
||||
self.appModel.gatewayRemoteAddress ?? "Waiting for gateway"
|
||||
}
|
||||
|
||||
var gatewayServer: String {
|
||||
self.appModel.gatewayServerName ?? "OpenClaw Gateway"
|
||||
}
|
||||
|
||||
var pendingApproval: NodeAppModel.ExecApprovalPrompt? {
|
||||
self.appModel.pendingExecApprovalPrompt
|
||||
}
|
||||
|
||||
var notificationsNeedAttention: Bool {
|
||||
switch self.notificationStatus {
|
||||
case .allowed, .checking:
|
||||
false
|
||||
case .notAllowed, .notSet, .unknown:
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
var approvalItems: [SettingsApprovalItem] {
|
||||
guard let pendingApproval else { return [] }
|
||||
return [
|
||||
SettingsApprovalItem(
|
||||
id: "pending-real",
|
||||
icon: "terminal.fill",
|
||||
title: pendingApproval.commandPreview ?? "Review gateway action",
|
||||
detail: "Agent: \(self.appModel.activeAgentName)",
|
||||
priority: self.appModel.pendingExecApprovalPromptResolving ? "Resolving" : "High",
|
||||
color: OpenClawBrand.danger),
|
||||
SettingsApprovalItem(
|
||||
id: "pending-context",
|
||||
icon: "doc.text.fill",
|
||||
title: pendingApproval.allowsAllowAlways ? "Permission can be saved" : "One-time approval",
|
||||
detail: "Gateway request",
|
||||
priority: pendingApproval.allowsAllowAlways ? "Medium" : "Review",
|
||||
color: OpenClawBrand.warn),
|
||||
]
|
||||
}
|
||||
|
||||
var voiceDetail: String {
|
||||
if self.talkEnabled, self.voiceWakeEnabled { return "Talk + Wake" }
|
||||
if self.talkEnabled { return "Talk on" }
|
||||
if self.voiceWakeEnabled { return "Wake on" }
|
||||
return "Off"
|
||||
}
|
||||
|
||||
var diagnosticsHealthValue: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
if self.gatewayConnected { return "ready" }
|
||||
if self.gatewayController.gateways.isEmpty { return "check" }
|
||||
return "partial"
|
||||
}
|
||||
|
||||
var diagnosticsRunValue: String {
|
||||
guard let diagnosticsIssueCount else { return "pending" }
|
||||
return diagnosticsIssueCount == 0 ? "pass" : "\(diagnosticsIssueCount)"
|
||||
}
|
||||
|
||||
var diagnosticsRunColor: Color {
|
||||
guard let diagnosticsIssueCount else { return .secondary }
|
||||
return diagnosticsIssueCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn
|
||||
}
|
||||
|
||||
var privacyDetail: String {
|
||||
let location = OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||
return switch (location, self.locationPermissionSummary.effectiveMode) {
|
||||
case (.off, _):
|
||||
"Location off"
|
||||
case (.whileUsing, .whileUsing):
|
||||
"Location While Using"
|
||||
case (.whileUsing, .off):
|
||||
"Location While Using, effective Off"
|
||||
case (.whileUsing, .always):
|
||||
"Location While Using, effective Always"
|
||||
case (.always, .always):
|
||||
"Location Always"
|
||||
case (.always, .whileUsing):
|
||||
"Location Always, effective While Using"
|
||||
case (.always, .off):
|
||||
"Location Always, effective Off"
|
||||
}
|
||||
}
|
||||
|
||||
var locationPermissionDetailText: String {
|
||||
if self.isChangingLocationMode {
|
||||
return "Requesting iOS location permission…"
|
||||
}
|
||||
return self.locationPermissionSummary.detailText
|
||||
}
|
||||
|
||||
var locationPermissionWarningText: String? {
|
||||
guard let locationStatusText else { return nil }
|
||||
guard locationStatusText != self.locationPermissionSummary.detailText else { return nil }
|
||||
return locationStatusText
|
||||
}
|
||||
|
||||
var notificationStatusText: String {
|
||||
self.notificationStatus.text
|
||||
}
|
||||
|
||||
var notificationActionText: String {
|
||||
self.notificationStatus.actionTitle
|
||||
}
|
||||
|
||||
var notificationStatusDetail: String {
|
||||
switch self.notificationStatus {
|
||||
case .checking:
|
||||
"Checking iOS notification permission."
|
||||
case .allowed:
|
||||
"OpenClaw can show approval prompts and event alerts when the app is not active."
|
||||
case .notAllowed:
|
||||
"Notifications have been denied. Enable them in iOS Settings."
|
||||
case .notSet:
|
||||
"Enable notifications to receive approval prompts and event alerts outside the app."
|
||||
case .unknown:
|
||||
"OpenClaw cannot determine the current notification permission state."
|
||||
}
|
||||
}
|
||||
|
||||
var notificationRelayDetail: String {
|
||||
if PushBuildConfig.current.usesOpenClawHostedRelay {
|
||||
let host = PushBuildConfig.current.relayBaseURL.flatMap {
|
||||
URLComponents(url: $0, resolvingAgainstBaseURL: false)?.host
|
||||
} ?? "ios-push-relay.openclaw.ai"
|
||||
return """
|
||||
This build uses OpenClaw's hosted push relay at \(host) for notification \
|
||||
delivery data.
|
||||
"""
|
||||
}
|
||||
return "This build is not configured to use OpenClaw's hosted push relay."
|
||||
}
|
||||
|
||||
var notificationRelayDisclosureMessage: String {
|
||||
"Enabling this sends delivery data through OpenClaw's hosted push relay."
|
||||
}
|
||||
}
|
||||
994
apps/ios/Sources/Design/SettingsProTabSections.swift
Normal file
994
apps/ios/Sources/Design/SettingsProTabSections.swift
Normal file
@@ -0,0 +1,994 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
/// iOS Settings-style icon: white glyph on a solid rounded-square, sized for a List row.
|
||||
struct SettingsIcon: View {
|
||||
let systemName: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: self.systemName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(RoundedRectangle(cornerRadius: 7, style: .continuous).fill(self.color))
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppearanceSettingsRow: View {
|
||||
@Environment(AppAppearanceModel.self) private var appearanceModel
|
||||
|
||||
private var preference: AppAppearancePreference {
|
||||
self.appearanceModel.preference
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationLink {
|
||||
AppearanceSettingsScreen()
|
||||
} label: {
|
||||
self.rowLabel
|
||||
}
|
||||
.accessibilityIdentifier("settings-appearance-row")
|
||||
.accessibilityLabel("Appearance")
|
||||
.accessibilityValue(self.preference.label)
|
||||
.accessibilityHint("Choose system, light, or dark appearance")
|
||||
}
|
||||
|
||||
private var rowLabel: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: "circle.lefthalf.filled",
|
||||
color: .secondary)
|
||||
|
||||
Text("Appearance")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Text(self.preference.label)
|
||||
.font(OpenClawType.subhead)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppearanceSettingsScreen: View {
|
||||
@Environment(AppAppearanceModel.self) private var appearanceModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(AppAppearancePreference.allCases) { preference in
|
||||
Button {
|
||||
self.select(preference)
|
||||
} label: {
|
||||
Label {
|
||||
HStack {
|
||||
Text(preference.label)
|
||||
.font(OpenClawType.body)
|
||||
Spacer()
|
||||
if preference == self.appearanceModel.preference {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
}
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: preference.systemImage)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.accessibilityIdentifier("settings-appearance-\(preference.rawValue)")
|
||||
.accessibilityValue(
|
||||
preference == self.appearanceModel.preference ? "Selected" : "")
|
||||
}
|
||||
} footer: {
|
||||
Text("System follows this device’s appearance setting.")
|
||||
.font(OpenClawType.footnote)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Appearance")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func select(_ preference: AppAppearancePreference) {
|
||||
guard preference != self.appearanceModel.preference else { return }
|
||||
self.dismiss()
|
||||
Task { @MainActor in
|
||||
// Changing the root scheme while an iPad split-view destination is active can
|
||||
// leave that destination blank. Apply only after the native pop transition.
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
self.appearanceModel.select(preference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsProTab {
|
||||
var appearanceRow: some View {
|
||||
AppearanceSettingsRow()
|
||||
}
|
||||
|
||||
var gatewaySection: some View {
|
||||
Section("Gateway") {
|
||||
NavigationLink(value: SettingsRoute.gateway) {
|
||||
self.gatewayConnectionRow
|
||||
}
|
||||
LabeledContent("Address", value: self.gatewayAddress)
|
||||
LabeledContent("Server", value: self.gatewayServer)
|
||||
LabeledContent("Agents", value: "\(self.appModel.gatewayAgents.count)")
|
||||
self.gatewayActions
|
||||
}
|
||||
}
|
||||
|
||||
var gatewayConnectionRow: some View {
|
||||
LabeledContent {
|
||||
Text(self.gatewayStatusDetail)
|
||||
.font(OpenClawType.subhead)
|
||||
.foregroundStyle(self.gatewayStatusColor)
|
||||
} label: {
|
||||
Text("Connection")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var settingsListSection: some View {
|
||||
Section {
|
||||
self.settingsListRow(
|
||||
icon: "checkmark.shield.fill",
|
||||
iconColor: self.pendingApproval == nil ? .green : .orange,
|
||||
title: "Approvals",
|
||||
route: .approvals,
|
||||
badgeValue: self.pendingApproval == nil ? nil : "1")
|
||||
self.settingsListRow(
|
||||
icon: "person.2.fill",
|
||||
iconColor: .blue,
|
||||
title: "Permissions",
|
||||
route: .permissions)
|
||||
self.settingsListRow(
|
||||
icon: "point.3.connected.trianglepath.dotted",
|
||||
iconColor: .purple,
|
||||
title: "Channels",
|
||||
route: .channels)
|
||||
self.settingsListRow(
|
||||
icon: "waveform",
|
||||
iconColor: .pink,
|
||||
title: "Voice & Talk",
|
||||
route: .voice)
|
||||
}
|
||||
|
||||
Section {
|
||||
self.appearanceRow
|
||||
self.settingsListRow(
|
||||
icon: "stethoscope",
|
||||
iconColor: .teal,
|
||||
title: "Diagnostics",
|
||||
route: .diagnostics)
|
||||
self.settingsListRow(
|
||||
icon: "hand.raised.fill",
|
||||
iconColor: .indigo,
|
||||
title: "Privacy",
|
||||
route: .privacy)
|
||||
self.settingsListRow(
|
||||
icon: "bell.fill",
|
||||
iconColor: .red,
|
||||
title: "Notifications",
|
||||
route: .notifications)
|
||||
self.settingsListRow(
|
||||
icon: "info.circle.fill",
|
||||
iconColor: .gray,
|
||||
title: "About",
|
||||
route: .about)
|
||||
} header: {
|
||||
Text("Device")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
self.settingsListRow(
|
||||
icon: "doc.text",
|
||||
iconColor: .gray,
|
||||
title: "Licenses",
|
||||
route: .licenses)
|
||||
.accessibilityIdentifier("settings-licenses-row")
|
||||
}
|
||||
}
|
||||
|
||||
func settingsListRow(
|
||||
icon: String,
|
||||
iconColor: Color,
|
||||
title: String,
|
||||
route: SettingsRoute,
|
||||
badgeValue: String? = nil) -> some View
|
||||
{
|
||||
NavigationLink(value: route) {
|
||||
Label {
|
||||
Text(title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
} icon: {
|
||||
SettingsIcon(systemName: icon, color: iconColor)
|
||||
}
|
||||
}
|
||||
.badge(badgeValue.map { Text($0).font(OpenClawType.captionSemiBold) })
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func destination(for route: SettingsRoute) -> some View {
|
||||
switch route {
|
||||
case .channels:
|
||||
SettingsChannelsDestination()
|
||||
.navigationTitle(title(for: route))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
default:
|
||||
List {
|
||||
switch route {
|
||||
case .gateway:
|
||||
self.gatewayDestination
|
||||
case .approvals:
|
||||
self.approvalsDestination
|
||||
case .permissions:
|
||||
self.permissionsDestination
|
||||
case .voice:
|
||||
self.voiceDestination
|
||||
case .diagnostics:
|
||||
self.diagnosticsDestination
|
||||
case .privacy:
|
||||
self.privacyDestination
|
||||
case .notifications:
|
||||
self.notificationsDestination
|
||||
case .about:
|
||||
self.aboutDestination
|
||||
case .licenses:
|
||||
self.licensesDestination
|
||||
case .channels:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.font(OpenClawType.body)
|
||||
.navigationTitle(title(for: route))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if let headerLeadingAction {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text(title(for: route))
|
||||
.font(OpenClawType.headline)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var gatewayDestination: some View {
|
||||
Group {
|
||||
self.detailStatusCard(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "Gateway",
|
||||
detail: self.gatewayStatusDetail,
|
||||
value: self.gatewayStatusValue,
|
||||
color: self.gatewayStatusColor)
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Address", value: self.gatewayAddress)
|
||||
self.detailRow("Server", value: self.gatewayServer)
|
||||
self.detailRow("Discovered", value: "\(self.gatewayController.gateways.count)")
|
||||
self.detailRow("Default Agent", value: self.appModel.activeAgentName)
|
||||
self.detailRow("Agents", value: "\(self.appModel.gatewayAgents.count)")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task { await self.reconnectGateway() }
|
||||
} label: {
|
||||
Label("Reconnect", systemImage: "arrow.triangle.2.circlepath")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.disabled(self.isReconnectingGateway || self.appModel.isAppleReviewDemoModeEnabled)
|
||||
Button {
|
||||
Task { await self.runDiagnostics() }
|
||||
} label: {
|
||||
Label("Diagnose", systemImage: "cross.case")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.disabled(self.isRefreshingGateway)
|
||||
}
|
||||
|
||||
self.manualGatewayCard
|
||||
self.deviceIdentityCard
|
||||
self.agentSelectionCard
|
||||
self.gatewaySetupCard
|
||||
self.discoveredGatewaysCard
|
||||
self.gatewayAdvancedCard
|
||||
}
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
|
||||
var approvalsDestination: some View {
|
||||
Group {
|
||||
self.detailStatusCard(
|
||||
icon: "checkmark.shield.fill",
|
||||
title: "Approvals",
|
||||
detail: self.notificationsNeedAttention
|
||||
? "Out-of-app approval alerts need notification permission."
|
||||
: (self.pendingApproval == nil ? "No gateway actions are waiting for review." :
|
||||
"Review the pending gateway action."),
|
||||
value: self.notificationsNeedAttention
|
||||
? "Alerts Off"
|
||||
: (self.pendingApproval == nil ? "clear" : "1 waiting"),
|
||||
color: self.notificationsNeedAttention ? OpenClawBrand.warn :
|
||||
(self.pendingApproval == nil ? OpenClawBrand.ok : OpenClawBrand.warn))
|
||||
|
||||
if self.notificationsNeedAttention {
|
||||
self.approvalNotificationsWarningCard
|
||||
}
|
||||
|
||||
self.approvalsReviewCard
|
||||
}
|
||||
}
|
||||
|
||||
var approvalNotificationsWarningCard: some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Notifications are off")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text("Enable Notifications to receive approval alerts while OpenClaw is not open.")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if self.directRoute == nil {
|
||||
Button {
|
||||
self.openNotificationsRouteFromApprovals()
|
||||
} label: {
|
||||
Label("Open Notifications", systemImage: "bell.badge")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var approvalsReviewCard: some View {
|
||||
if let pendingApproval {
|
||||
Section {
|
||||
ForEach(self.approvalItems, id: \.id) { item in
|
||||
SettingsApprovalRow(item: item)
|
||||
}
|
||||
if let errorText = self.appModel.pendingExecApprovalPromptErrorText {
|
||||
Text(errorText)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(OpenClawBrand.danger)
|
||||
}
|
||||
Button {
|
||||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
|
||||
} label: {
|
||||
Label("Allow", systemImage: "checkmark")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
if pendingApproval.allowsAllowAlways {
|
||||
Button {
|
||||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always") }
|
||||
} label: {
|
||||
Label("Always Allow", systemImage: "checkmark.shield")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
|
||||
} label: {
|
||||
Label("Deny", systemImage: "xmark")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("No approvals waiting")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(self.approvalEmptyDetail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.foregroundStyle(OpenClawBrand.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var permissionsDestination: some View {
|
||||
Group {
|
||||
self.toggleCard(
|
||||
title: "Camera",
|
||||
isOn: self.$cameraEnabled)
|
||||
|
||||
self.locationModeCard
|
||||
|
||||
self.toggleCard(
|
||||
title: "Keep Awake",
|
||||
isOn: self.$preventSleep)
|
||||
|
||||
self.privacyAccessCard
|
||||
}
|
||||
}
|
||||
|
||||
var voiceDestination: some View {
|
||||
Group {
|
||||
self.detailStatusCard(
|
||||
icon: "waveform",
|
||||
title: "Voice & Talk",
|
||||
detail: self.appModel.talkMode.gatewayTalkVoiceModeTitle,
|
||||
value: self.voiceDetail,
|
||||
color: self.talkEnabled || self.voiceWakeEnabled ? OpenClawBrand.accent : .secondary)
|
||||
|
||||
self.voiceFeatureCard
|
||||
self.talkVoiceSettingsCard
|
||||
self.shareSettingsCard
|
||||
}
|
||||
}
|
||||
|
||||
var diagnosticsDestination: some View {
|
||||
Group {
|
||||
self.detailStatusCard(
|
||||
icon: "checklist.checked",
|
||||
title: "Health Check",
|
||||
detail: "Run app, permission, and gateway-adjacent checks without editing setup.",
|
||||
value: self.diagnosticsHealthValue,
|
||||
color: self.gatewayDiagnosticConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task { await self.runDiagnostics() }
|
||||
} label: {
|
||||
Label("Run Diagnostics", systemImage: "cross.case")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.disabled(self.isRefreshingGateway)
|
||||
}
|
||||
|
||||
self.diagnosticChecksCard
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||||
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
|
||||
self.detailRow("App", value: DeviceInfoHelper.openClawVersionString())
|
||||
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
|
||||
}
|
||||
|
||||
self.diagnosticsAdvancedCard
|
||||
}
|
||||
}
|
||||
|
||||
var privacyDestination: some View {
|
||||
Group {
|
||||
self.detailStatusCard(
|
||||
icon: "hand.raised",
|
||||
title: "Privacy",
|
||||
detail: "Control what device context OpenClaw can expose to the gateway.",
|
||||
value: self.privacyDetail,
|
||||
color: .secondary)
|
||||
|
||||
self.toggleCard(
|
||||
title: "Camera Access",
|
||||
isOn: self.$cameraEnabled)
|
||||
|
||||
self.locationModeCard
|
||||
|
||||
self.toggleCard(
|
||||
title: "Background Listening",
|
||||
isOn: self.$talkBackgroundEnabled)
|
||||
|
||||
self.privacyAccessCard
|
||||
}
|
||||
}
|
||||
|
||||
var notificationsDestination: some View {
|
||||
Group {
|
||||
self.detailStatusCard(
|
||||
icon: "bell",
|
||||
title: "Notifications",
|
||||
detail: self.notificationStatusDetail,
|
||||
value: self.notificationStatusText,
|
||||
color: self.notificationStatus.color)
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Button {
|
||||
self.handleNotificationAction()
|
||||
} label: {
|
||||
Label(
|
||||
self.notificationActionText,
|
||||
systemImage: self.notificationStatus.actionIcon)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(self.notificationStatus == .checking || self.isRequestingNotificationAuthorization)
|
||||
|
||||
Text(self.notificationStatusDetail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "network")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.frame(width: 22, height: 22)
|
||||
Text(self.notificationRelayDetail)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var gatewayActions: some View {
|
||||
Group {
|
||||
self.gatewayActionButton(
|
||||
title: "Reconnect",
|
||||
icon: "arrow.triangle.2.circlepath",
|
||||
color: OpenClawBrand.accent,
|
||||
isBusy: self.isReconnectingGateway,
|
||||
isDisabled: self.appModel.isAppleReviewDemoModeEnabled)
|
||||
{
|
||||
Task { await self.reconnectGateway() }
|
||||
}
|
||||
|
||||
self.gatewayActionButton(
|
||||
title: "Diagnose",
|
||||
icon: "cross.case",
|
||||
color: OpenClawBrand.accent,
|
||||
isBusy: self.isRefreshingGateway)
|
||||
{
|
||||
Task { await self.runDiagnostics() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var licensesDestination: some View {
|
||||
let documents = LicenseDocumentLoader.bundledDocuments()
|
||||
if documents.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Licenses Bundled",
|
||||
systemImage: "doc.text",
|
||||
description: Text("License files are not available in this build."))
|
||||
.font(OpenClawType.body)
|
||||
} else {
|
||||
Section {
|
||||
ForEach(documents) { document in
|
||||
NavigationLink {
|
||||
LicenseDocumentDetailView(document: document)
|
||||
} label: {
|
||||
Label {
|
||||
Text(document.title)
|
||||
.font(OpenClawType.subhead)
|
||||
} icon: {
|
||||
SettingsIcon(systemName: "doc.text", color: .gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("OpenClaw appreciates its partners in the open-source community.")
|
||||
.font(OpenClawType.footnote)
|
||||
}
|
||||
.accessibilityIdentifier("settings-licenses-list")
|
||||
}
|
||||
}
|
||||
|
||||
/// Native inset-grouped action row (plain tinted text, no pill chrome).
|
||||
func gatewayActionButton(
|
||||
title: String,
|
||||
icon: String,
|
||||
color: Color,
|
||||
isBusy: Bool,
|
||||
isDisabled: Bool = false,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Label(title, systemImage: icon)
|
||||
.font(OpenClawType.body)
|
||||
Spacer()
|
||||
if isBusy {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(color)
|
||||
.disabled(isBusy || isDisabled)
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
|
||||
var aboutDestination: some View {
|
||||
// Concise public details only; deep hardware identifiers live in Diagnostics.
|
||||
detailListCard {
|
||||
self.detailRow("OpenClaw app version", value: DeviceInfoHelper.openClawVersionString())
|
||||
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||||
self.detailRow("iOS", value: DeviceInfoHelper.iOSVersionStringForDisplay())
|
||||
}
|
||||
}
|
||||
|
||||
func toggleCard(title: String, isOn: Binding<Bool>) -> some View {
|
||||
Section {
|
||||
Toggle(isOn: isOn) {
|
||||
Text(title)
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var locationModeCard: some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
SettingsIcon(
|
||||
systemName: "location",
|
||||
color: self.locationModeRaw == OpenClawLocationMode.off.rawValue ? .secondary : OpenClawBrand
|
||||
.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Location")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text("Controls whether location can be shared with gateway tools.")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if self.isChangingLocationMode {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Location", selection: self.$locationModeRaw) {
|
||||
Text("Off")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.tag(OpenClawLocationMode.off.rawValue)
|
||||
Text("While Using")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.tag(OpenClawLocationMode.whileUsing.rawValue)
|
||||
Text("Always")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.tag(OpenClawLocationMode.always.rawValue)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(self.isChangingLocationMode)
|
||||
|
||||
Text(self.locationPermissionDetailText)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(
|
||||
self.locationPermissionSummary.needsAttention ? OpenClawBrand.warn : .secondary)
|
||||
|
||||
if let locationPermissionWarningText {
|
||||
Text(locationPermissionWarningText)
|
||||
.font(OpenClawType.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var agentSelectionCard: some View {
|
||||
Section {
|
||||
Picker("Default Agent", selection: self.$selectedAgentPickerId) {
|
||||
Text("Default").font(OpenClawType.body).tag("")
|
||||
let defaultId = (self.appModel.gatewayDefaultAgentId ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in
|
||||
let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
Text(name.isEmpty ? agent.id : name).font(OpenClawType.body).tag(agent.id)
|
||||
}
|
||||
}
|
||||
.font(OpenClawType.body)
|
||||
} footer: {
|
||||
Text("Used for new Chat and Talk sessions.")
|
||||
.font(OpenClawType.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
var gatewaySetupCard: some View {
|
||||
Section {
|
||||
TextField("Paste setup code", text: self.$setupCode)
|
||||
.font(OpenClawType.body)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
self.gatewayActionButton(
|
||||
title: "Scan QR",
|
||||
icon: "qrcode.viewfinder",
|
||||
color: OpenClawBrand.accent,
|
||||
isBusy: false,
|
||||
isDisabled: self.connectingGatewayID != nil)
|
||||
{
|
||||
self.openGatewayQRScanner()
|
||||
}
|
||||
|
||||
self.gatewayActionButton(
|
||||
title: "Connect",
|
||||
icon: "bolt.horizontal.circle",
|
||||
color: OpenClawBrand.accent,
|
||||
isBusy: self.connectingGatewayID == "manual",
|
||||
isDisabled: !self.canApplyGatewaySetup || self.connectingGatewayID != nil)
|
||||
{
|
||||
Task { await self.applySetupCodeAndConnect() }
|
||||
}
|
||||
} header: {
|
||||
Text("Setup Code")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
} footer: {
|
||||
if let warning = self.tailnetWarningText {
|
||||
Text(warning).font(OpenClawType.footnote).foregroundStyle(OpenClawBrand.warn)
|
||||
} else if let status = self.setupStatusLine {
|
||||
Text(status)
|
||||
.font(OpenClawType.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var discoveredGatewaysCard: some View {
|
||||
Section("Discovered Gateways") {
|
||||
if self.gatewayController.gateways.isEmpty {
|
||||
Text("No gateways found yet. Use manual setup if Bonjour is blocked.")
|
||||
.font(OpenClawType.subhead)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.gatewayController.gateways) { gateway in
|
||||
self.discoveredGatewayRow(gateway)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func discoveredGatewayRow(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(verbatim: gateway.name)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
Text(verbatim: self.gatewayDetailLines(gateway).joined(separator: " • "))
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
Task { await self.connect(gateway) }
|
||||
} label: {
|
||||
if self.connectingGatewayID == gateway.id {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Connect")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
}
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
|
||||
var manualGatewayCard: some View {
|
||||
Section("Manual Gateway") {
|
||||
Toggle(isOn: self.$manualGatewayEnabled) {
|
||||
Text("Use Manual Gateway")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
.font(OpenClawType.body)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Port", text: self.manualPortBinding)
|
||||
.font(OpenClawType.body)
|
||||
.keyboardType(.numberPad)
|
||||
Toggle(isOn: self.$manualGatewayTLS) {
|
||||
Text("Use TLS")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
self.gatewayActionButton(
|
||||
title: "Connect Manual",
|
||||
icon: "network",
|
||||
color: OpenClawBrand.accent,
|
||||
isBusy: self.connectingGatewayID == "manual",
|
||||
isDisabled: self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !self.manualPortIsValid)
|
||||
{
|
||||
Task { await self.connectManual() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var gatewayAdvancedCard: some View {
|
||||
Section {
|
||||
Toggle(isOn: self.$gatewayAutoConnect) {
|
||||
Text("Auto-connect on launch")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
self.gatewaySecureField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
self.gatewaySecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
Button(role: .destructive) {
|
||||
self.showResetOnboardingAlert = true
|
||||
} label: {
|
||||
Label("Reset Onboarding", systemImage: "arrow.counterclockwise")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func gatewaySecureField(_ placeholder: String, text: Binding<String>) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
SecureField("", text: text)
|
||||
.font(OpenClawType.subhead)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityLabel(placeholder)
|
||||
if text.wrappedValue.isEmpty {
|
||||
Text(placeholder)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 8)
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.font(OpenClawType.subhead)
|
||||
}
|
||||
|
||||
var voiceFeatureCard: some View {
|
||||
Section {
|
||||
self.settingsToggle("Voice Wake", isOn: self.$voiceWakeEnabled) { enabled in
|
||||
self.appModel.setVoiceWakeEnabled(enabled)
|
||||
}
|
||||
self.settingsToggle("Talk Mode", isOn: self.$talkEnabled) { enabled in
|
||||
guard !self.appModel.isAppleReviewDemoModeEnabled else {
|
||||
self.talkEnabled = false
|
||||
return
|
||||
}
|
||||
self.appModel.setTalkEnabled(enabled)
|
||||
}
|
||||
.disabled(self.appModel.isAppleReviewDemoModeEnabled)
|
||||
Picker("Speech Language", selection: self.$talkSpeechLocale) {
|
||||
ForEach(TalkSpeechLocale.supportedOptions()) { option in
|
||||
Text(option.label).font(OpenClawType.body).tag(option.id)
|
||||
}
|
||||
}
|
||||
.font(OpenClawType.body)
|
||||
self.settingsToggle("Background Listening", isOn: self.$talkBackgroundEnabled)
|
||||
self.settingsToggle("Speakerphone", isOn: self.talkSpeakerphoneBinding)
|
||||
NavigationLink {
|
||||
VoiceWakeWordsSettingsView()
|
||||
} label: {
|
||||
self.simpleSettingsRow(
|
||||
title: "Wake Words",
|
||||
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var talkVoiceSettingsCard: some View {
|
||||
Group {
|
||||
if self.gatewayConnected,
|
||||
let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue
|
||||
{
|
||||
Section {
|
||||
TalkRuntimeIssueBanner(
|
||||
issue: issue,
|
||||
onOpenSettings: nil,
|
||||
onShowDetails: {
|
||||
self.showTalkIssueDetails = true
|
||||
})
|
||||
}
|
||||
}
|
||||
Section("Voice") {
|
||||
Picker("Provider", selection: self.talkProviderSelectionBinding) {
|
||||
ForEach(TalkModeProviderSelection.allCases) { option in
|
||||
Text(option.label).font(OpenClawType.body).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.font(OpenClawType.body)
|
||||
if self.shouldShowRealtimeVoicePicker {
|
||||
Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) {
|
||||
Text("Gateway Default").font(OpenClawType.body).tag("")
|
||||
ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in
|
||||
Text(TalkModeRealtimeVoiceSelection.label(for: voice)).font(OpenClawType.body).tag(voice)
|
||||
}
|
||||
}
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
self.detailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
self.detailRow("Active Voice", value: self.gatewayTalkActiveVoiceDetail)
|
||||
if let issue = self.gatewayTalkLastIssueDetail {
|
||||
self.detailRow("Last Voice Issue", value: issue)
|
||||
}
|
||||
self.detailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel)
|
||||
self.detailRow("API Key", value: self.talkApiKeyStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var shareSettingsCard: some View {
|
||||
Section {
|
||||
Toggle(isOn: self.$talkButtonEnabled) {
|
||||
Text("Show Talk Control")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
|
||||
.font(OpenClawType.body)
|
||||
.lineLimit(2...5)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
Button {
|
||||
Task { await self.appModel.runSharePipelineSelfTest() }
|
||||
} label: {
|
||||
Label("Run Share Self-Test", systemImage: "checkmark.seal")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
} footer: {
|
||||
Text(self.appModel.lastShareEventText)
|
||||
.font(OpenClawType.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
var privacyAccessCard: some View {
|
||||
Section {
|
||||
PrivacyAccessSectionView()
|
||||
}
|
||||
}
|
||||
|
||||
var diagnosticsAdvancedCard: some View {
|
||||
Section {
|
||||
self.settingsToggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) { enabled in
|
||||
self.gatewayController.setDiscoveryDebugLoggingEnabled(enabled)
|
||||
}
|
||||
self.settingsToggle("Debug Screen Status", isOn: self.$canvasDebugStatusEnabled)
|
||||
NavigationLink {
|
||||
GatewayDiscoveryDebugLogView()
|
||||
} label: {
|
||||
self.simpleSettingsRow(title: "Discovery Logs", value: self.gatewayController.discoveryStatusText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var deviceIdentityCard: some View {
|
||||
Section("Device") {
|
||||
TextField("Device Name", text: self.$displayName)
|
||||
.font(OpenClawType.body)
|
||||
self.detailRow("Instance ID", value: self.instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
func settingsToggle(
|
||||
_ title: String,
|
||||
isOn: Binding<Bool>,
|
||||
onChange: ((Bool) -> Void)? = nil) -> some View
|
||||
{
|
||||
Toggle(isOn: isOn) {
|
||||
Text(title)
|
||||
.font(OpenClawType.subhead)
|
||||
}
|
||||
.onChange(of: isOn.wrappedValue) { _, enabled in
|
||||
onChange?(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func simpleSettingsRow(title: String, value: String) -> some View {
|
||||
LabeledContent(title) {
|
||||
Text(value)
|
||||
.font(OpenClawType.subhead)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
397
apps/ios/Sources/Design/SettingsProTabSupport.swift
Normal file
397
apps/ios/Sources/Design/SettingsProTabSupport.swift
Normal file
@@ -0,0 +1,397 @@
|
||||
import Darwin
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
enum SettingsRoute: Hashable {
|
||||
case gateway
|
||||
case approvals
|
||||
case permissions
|
||||
case channels
|
||||
case voice
|
||||
case diagnostics
|
||||
case privacy
|
||||
case notifications
|
||||
case licenses
|
||||
case about
|
||||
}
|
||||
|
||||
enum SettingsLayout {
|
||||
static let cardRadius: CGFloat = OpenClawProMetric.cardRadius
|
||||
static let rowHeight: CGFloat = 58
|
||||
}
|
||||
|
||||
struct SettingsApprovalItem: Identifiable {
|
||||
let id: String
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let priority: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
struct SettingsApprovalRow: View {
|
||||
let item: SettingsApprovalItem
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: self.item.icon)
|
||||
.font(OpenClawType.captionBold)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(self.item.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.item.title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.lineLimit(1)
|
||||
Text(self.item.detail)
|
||||
.font(OpenClawType.caption2Medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.item.priority)
|
||||
.font(OpenClawType.captionBold)
|
||||
.foregroundStyle(self.item.color)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 5)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.item.color.opacity(0.10))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 7)
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsNotificationStatus: Equatable {
|
||||
case checking
|
||||
case allowed
|
||||
case notAllowed
|
||||
case notSet
|
||||
case unknown
|
||||
|
||||
init(_ status: UNAuthorizationStatus) {
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
self = .allowed
|
||||
case .denied:
|
||||
self = .notAllowed
|
||||
case .notDetermined:
|
||||
self = .notSet
|
||||
@unknown default:
|
||||
self = .unknown
|
||||
}
|
||||
}
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .checking: "Checking"
|
||||
case .allowed: "Enabled"
|
||||
case .notAllowed: "Denied"
|
||||
case .notSet: "Not Enabled"
|
||||
case .unknown: "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
var actionTitle: String {
|
||||
switch self {
|
||||
case .notSet:
|
||||
"Enable Notifications"
|
||||
case .checking:
|
||||
"Checking"
|
||||
case .allowed:
|
||||
"Manage in iOS Settings"
|
||||
case .notAllowed, .unknown:
|
||||
"Open iOS Settings"
|
||||
}
|
||||
}
|
||||
|
||||
var actionIcon: String {
|
||||
switch self {
|
||||
case .allowed:
|
||||
"gear"
|
||||
case .notAllowed, .unknown:
|
||||
"gear.badge"
|
||||
case .checking:
|
||||
"hourglass"
|
||||
case .notSet:
|
||||
"bell.badge"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .allowed:
|
||||
OpenClawBrand.ok
|
||||
case .notAllowed, .unknown:
|
||||
OpenClawBrand.warn
|
||||
case .checking, .notSet:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
var shouldOpenNotificationSettings: Bool {
|
||||
switch self {
|
||||
case .allowed, .notAllowed, .unknown:
|
||||
true
|
||||
case .checking, .notSet:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
var allowsNotifications: Bool {
|
||||
self == .allowed
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
|
||||
case gatewayOffline
|
||||
case discoveryUnavailable
|
||||
case talkConfigMissing
|
||||
case notificationsUnavailable
|
||||
}
|
||||
|
||||
enum SettingsDiagnostics {
|
||||
static func issues(
|
||||
gatewayConnected: Bool,
|
||||
discoveredGatewayCount: Int,
|
||||
talkConfigLoaded: Bool,
|
||||
notificationsAllowed: Bool) -> [SettingsDiagnosticIssue]
|
||||
{
|
||||
var issues: [SettingsDiagnosticIssue] = []
|
||||
if !gatewayConnected { issues.append(.gatewayOffline) }
|
||||
if discoveredGatewayCount == 0 { issues.append(.discoveryUnavailable) }
|
||||
if gatewayConnected, !talkConfigLoaded { issues.append(.talkConfigMissing) }
|
||||
if !notificationsAllowed { issues.append(.notificationsUnavailable) }
|
||||
return issues
|
||||
}
|
||||
|
||||
static func issueCount(
|
||||
gatewayConnected: Bool,
|
||||
discoveredGatewayCount: Int,
|
||||
talkConfigLoaded: Bool,
|
||||
notificationsAllowed: Bool) -> Int
|
||||
{
|
||||
self.issues(
|
||||
gatewayConnected: gatewayConnected,
|
||||
discoveredGatewayCount: discoveredGatewayCount,
|
||||
talkConfigLoaded: talkConfigLoaded,
|
||||
notificationsAllowed: notificationsAllowed).count
|
||||
}
|
||||
|
||||
static func timestamp(_ date: Date) -> String {
|
||||
date.formatted(date: .omitted, time: .shortened)
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsProTab {
|
||||
static func hasTailnetIPv4() -> Bool {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return false }
|
||||
defer { freeifaddrs(addrList) }
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
guard let addrPtr = ptr.pointee.ifa_addr else { continue }
|
||||
let family = addrPtr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
var addr = addrPtr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(addrPtr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if self.isTailnetIPv4(ip) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func isTailnetHostOrIP(_ host: String) -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") { return true }
|
||||
return self.isTailnetIPv4(trimmed)
|
||||
}
|
||||
|
||||
static func isTailnetIPv4(_ ip: String) -> Bool {
|
||||
let parts = ip.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
guard (0...255).contains(a), (0...255).contains(b) else { return false }
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Gateway settings states") {
|
||||
SettingsGatewayStatesPreview()
|
||||
}
|
||||
|
||||
private struct SettingsGatewayStatesPreview: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.stateSection("Connected") {
|
||||
self.gatewayStatusCard(
|
||||
title: "Gateway online",
|
||||
detail: "Connected to openclaw-gateway.tailnet.ts.net.",
|
||||
value: "online",
|
||||
color: OpenClawBrand.ok)
|
||||
self.gatewayFactsCard(
|
||||
address: "100.88.41.20:18789",
|
||||
server: "openclaw-gateway",
|
||||
discovered: "3",
|
||||
agent: "Aiden")
|
||||
}
|
||||
|
||||
self.stateSection("Loading") {
|
||||
self.gatewayStatusCard(
|
||||
title: "Checking gateway",
|
||||
detail: "Refreshing connection, discovery, and device trust state.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent)
|
||||
self.gatewayActionsCard(isBusy: true)
|
||||
}
|
||||
|
||||
self.stateSection("Empty") {
|
||||
self.gatewayStatusCard(
|
||||
title: "No gateway configured",
|
||||
detail: "Scan a setup QR code, paste a setup code, or choose a discovered gateway.",
|
||||
value: "setup",
|
||||
color: .secondary)
|
||||
self.setupActionsCard
|
||||
}
|
||||
|
||||
self.stateSection("Error") {
|
||||
self.gatewayStatusCard(
|
||||
title: "Tailscale warning",
|
||||
detail: "Tailscale is off on this device. Turn it on, then try again.",
|
||||
value: "network",
|
||||
color: OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stateSection(
|
||||
_ title: String,
|
||||
@ViewBuilder content: () -> some View) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayStatusCard(
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: value == "online" ? "antenna.radiowaves.left.and.right" : "wifi.slash",
|
||||
title: title,
|
||||
detail: detail,
|
||||
value: value,
|
||||
color: color,
|
||||
actionTitle: value == "setup" ? "Scan QR" : nil,
|
||||
action: value == "setup" ? {} : nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayFactsCard(
|
||||
address: String,
|
||||
server: String,
|
||||
discovered: String,
|
||||
agent: String) -> some View
|
||||
{
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
self.factRow("Address", value: address)
|
||||
Divider()
|
||||
self.factRow("Server", value: server)
|
||||
Divider()
|
||||
self.factRow("Discovered", value: discovered)
|
||||
Divider()
|
||||
self.factRow("Default Agent", value: agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func factRow(_ label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(OpenClawType.captionMedium)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.frame(height: SettingsLayout.rowHeight)
|
||||
}
|
||||
|
||||
private func gatewayActionsCard(isBusy: Bool) -> some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
HStack(spacing: 10) {
|
||||
self.previewButton("Reconnect", systemImage: "arrow.triangle.2.circlepath", isBusy: isBusy)
|
||||
self.previewButton("Diagnose", systemImage: "cross.case", isBusy: isBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var setupActionsCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
self.previewButton("Scan QR", systemImage: "qrcode.viewfinder", isBusy: false)
|
||||
self.previewButton("Connect", systemImage: "link", isBusy: false)
|
||||
}
|
||||
Text("Discovered gateways and manual setup live here when the gateway has not connected yet.")
|
||||
.font(OpenClawType.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewButton(
|
||||
_ title: String,
|
||||
systemImage: String,
|
||||
isBusy: Bool) -> some View
|
||||
{
|
||||
Button {} label: {
|
||||
Label(title, systemImage: systemImage)
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(isBusy)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
455
apps/ios/Sources/Design/TalkProTab.swift
Normal file
455
apps/ios/Sources/Design/TalkProTab.swift
Normal file
@@ -0,0 +1,455 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TalkProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage(TalkSpeechLocale.storageKey) private var talkSpeechLocale: String = TalkSpeechLocale.automaticID
|
||||
@AppStorage(TalkDefaults.speakerphoneEnabledKey) private var talkSpeakerphoneEnabled: Bool =
|
||||
TalkDefaults.speakerphoneEnabledByDefault
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@State private var showPermissionPrompt = false
|
||||
@State private var showTalkIssueDetails = false
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let ownsNavigationStack: Bool
|
||||
var openSettings: () -> Void
|
||||
var openVoiceSettings: () -> Void
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
ownsNavigationStack: Bool = true,
|
||||
openSettings: @escaping () -> Void,
|
||||
openVoiceSettings: (() -> Void)? = nil)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.ownsNavigationStack = ownsNavigationStack
|
||||
self.openSettings = openSettings
|
||||
self.openVoiceSettings = openVoiceSettings ?? openSettings
|
||||
}
|
||||
|
||||
private var state: TalkProState {
|
||||
TalkProState(
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
isDemoMode: self.appModel.isAppleReviewDemoModeEnabled,
|
||||
isEnabled: self.appModel.talkMode.isEnabled || self.talkEnabled,
|
||||
statusText: self.appModel.talkMode.statusText,
|
||||
isConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded,
|
||||
isListening: self.appModel.talkMode.isListening,
|
||||
isSpeaking: self.appModel.talkMode.isSpeaking,
|
||||
isUserSpeechDetected: self.appModel.talkMode.isUserSpeechDetected,
|
||||
permissionState: self.appModel.talkMode.gatewayTalkPermissionState)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if self.ownsNavigationStack {
|
||||
NavigationStack {
|
||||
self.content
|
||||
}
|
||||
} else {
|
||||
self.content
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showPermissionPrompt) {
|
||||
NavigationStack {
|
||||
TalkPermissionPromptView(
|
||||
style: .sheet,
|
||||
onPermissionReady: {
|
||||
self.showPermissionPrompt = false
|
||||
self.startTalk()
|
||||
})
|
||||
.padding()
|
||||
.navigationTitle("Enable Talk")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button {
|
||||
self.showPermissionPrompt = false
|
||||
} label: {
|
||||
Text("Not Now")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.openClawSheetChrome()
|
||||
}
|
||||
.sheet(isPresented: self.$showTalkIssueDetails) {
|
||||
if let fallbackIssue = self.fallbackIssue {
|
||||
TalkRuntimeIssueDetailsSheet(
|
||||
issue: fallbackIssue,
|
||||
onOpenSettings: self.openVoiceSettings)
|
||||
.openClawSheetChrome()
|
||||
}
|
||||
}
|
||||
.onAppear { self.alignPersistedTalkState() }
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
List {
|
||||
if let fallbackIssue = self.fallbackIssue {
|
||||
Section {
|
||||
TalkRuntimeIssueBanner(
|
||||
issue: fallbackIssue,
|
||||
onOpenSettings: self.openVoiceSettings,
|
||||
onShowDetails: {
|
||||
self.showTalkIssueDetails = true
|
||||
})
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
self.heroSection
|
||||
self.conversationSection
|
||||
self.voiceModeSection
|
||||
self.controlsSection
|
||||
}
|
||||
.navigationTitle("Talk")
|
||||
.toolbar {
|
||||
if let headerLeadingAction {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
OpenClawSidebarRevealButton(action: headerLeadingAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var heroSection: some View {
|
||||
Section {
|
||||
VStack(spacing: 16) {
|
||||
TalkSiriWaveView(mode: self.state.waveformMode(micLevel: self.appModel.talkMode.micLevel))
|
||||
.frame(height: 130)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text(self.state.title)
|
||||
.font(OpenClawType.title3SemiBold)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(self.heroSubtitle)
|
||||
.font(OpenClawType.subhead)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button(action: self.handlePrimaryAction) {
|
||||
Label(self.state.primaryButtonTitle, systemImage: self.state.primaryButtonIcon)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
// Match the icon to the label; otherwise the symbol picks up the tint color.
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.tint(self.state.color)
|
||||
.disabled(self.state.primaryAction == .waiting)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
private var conversationSection: some View {
|
||||
Section("Conversation") {
|
||||
LabeledContent("Agent", value: self.appModel.chatAgentName)
|
||||
LabeledContent("Session", value: self.appModel.chatSessionKey)
|
||||
LabeledContent("Runtime", value: self.appModel.talkMode.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
private var voiceModeSection: some View {
|
||||
Section("Voice Mode") {
|
||||
LabeledContent("Configured", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
LabeledContent("Active", value: self.activeModeText)
|
||||
LabeledContent("Transport", value: self.transportText)
|
||||
if let issueText = self.talkIssueText {
|
||||
LabeledContent("Last issue", value: issueText)
|
||||
}
|
||||
LabeledContent("Permission", value: self.permissionText)
|
||||
LabeledContent("Speech language", value: self.speechLocaleText)
|
||||
}
|
||||
}
|
||||
|
||||
private var controlsSection: some View {
|
||||
Section("Controls") {
|
||||
Toggle(isOn: self.talkSpeakerphoneBinding) {
|
||||
Text("Speakerphone")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.accessibilityIdentifier("talk-speakerphone-control")
|
||||
Toggle(isOn: self.$talkBackgroundEnabled) {
|
||||
Text("Background listening")
|
||||
.font(OpenClawType.body)
|
||||
}
|
||||
.accessibilityIdentifier("talk-background-listening-control")
|
||||
Button(action: self.openVoiceSettings) {
|
||||
HStack {
|
||||
Label("Voice & Talk Settings", systemImage: "slider.horizontal.3")
|
||||
.font(OpenClawType.body)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(OpenClawType.footnoteSemiBold)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("talk-voice-settings-control")
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
!self.appModel.isAppleReviewDemoModeEnabled &&
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var fallbackIssue: TalkRuntimeIssue? {
|
||||
guard self.gatewayConnected else { return nil }
|
||||
return self.appModel.talkMode.gatewayTalkCurrentFallbackIssue
|
||||
}
|
||||
|
||||
private var heroSubtitle: String {
|
||||
if self.state
|
||||
.prefersPermissionCopy { return "Gateway approval is required before this phone can capture voice." }
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "Voice is disabled in Apple Review demo mode." }
|
||||
if !self.gatewayConnected { return "Connect to your gateway to start a voice conversation." }
|
||||
if !self.appModel.talkMode.gatewayTalkConfigLoaded {
|
||||
return "Open Voice settings after the gateway loads Talk configuration."
|
||||
}
|
||||
let subtitle = (appModel.talkMode.gatewayTalkVoiceModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !subtitle.isEmpty { return subtitle }
|
||||
return "Routes voice to \(self.appModel.chatAgentName)."
|
||||
}
|
||||
|
||||
private var transportText: String {
|
||||
let provider = self.appModel.talkMode.gatewayTalkProviderLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let transport = self.appModel.talkMode.gatewayTalkTransportLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if provider.isEmpty || provider == "Not loaded" { return transport.isEmpty ? "Not loaded" : transport }
|
||||
if transport.isEmpty || transport == "Not loaded" { return provider }
|
||||
return "\(provider) • \(transport)"
|
||||
}
|
||||
|
||||
private var activeModeText: String {
|
||||
let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = (appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if title.isEmpty { return "Not active" }
|
||||
if subtitle.isEmpty { return title }
|
||||
return "\(title) • \(subtitle)"
|
||||
}
|
||||
|
||||
private var talkIssueText: String? {
|
||||
let text = (appModel.talkMode.gatewayTalkLastIssueText ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
private var permissionText: String {
|
||||
if let failure = appModel.talkMode.gatewayTalkPermissionState.failureMessage {
|
||||
return failure
|
||||
}
|
||||
return self.appModel.talkMode.gatewayTalkPermissionState.statusLabel
|
||||
}
|
||||
|
||||
private var speechLocaleText: String {
|
||||
if self.talkSpeechLocale == TalkSpeechLocale.automaticID { return "Automatic" }
|
||||
return self.talkSpeechLocale
|
||||
}
|
||||
|
||||
private func alignPersistedTalkState() {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled,
|
||||
self.talkEnabled || self.appModel.talkMode.isEnabled
|
||||
{
|
||||
self.stopTalk()
|
||||
} else if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction,
|
||||
self.talkEnabled || self.appModel.talkMode.isEnabled
|
||||
{
|
||||
self.stopTalk()
|
||||
} else if self.talkEnabled != self.appModel.talkMode.isEnabled {
|
||||
self.appModel.setTalkEnabled(self.talkEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private var talkSpeakerphoneBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { self.talkSpeakerphoneEnabled },
|
||||
set: { enabled in
|
||||
self.talkSpeakerphoneEnabled = enabled
|
||||
self.appModel.setTalkSpeakerphoneEnabled(enabled)
|
||||
})
|
||||
}
|
||||
|
||||
private func handlePrimaryAction() {
|
||||
switch self.state.primaryAction {
|
||||
case .start:
|
||||
self.startTalk()
|
||||
case .stop:
|
||||
self.stopTalk()
|
||||
case .enablePermission:
|
||||
self.stopTalk()
|
||||
self.showPermissionPrompt = true
|
||||
case .openSettings:
|
||||
self.openPrimarySettings()
|
||||
case .waiting:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func startTalk() {
|
||||
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
|
||||
self.talkEnabled = true
|
||||
self.appModel.talkMode.updateMainSessionKey(self.appModel.chatSessionKey)
|
||||
self.appModel.setTalkEnabled(true)
|
||||
}
|
||||
|
||||
private func stopTalk() {
|
||||
self.talkEnabled = false
|
||||
self.appModel.setTalkEnabled(false)
|
||||
}
|
||||
|
||||
private func openPrimarySettings() {
|
||||
if self.gatewayConnected {
|
||||
self.openVoiceSettings()
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TalkProPrimaryAction: Equatable {
|
||||
case start
|
||||
case stop
|
||||
case enablePermission
|
||||
case openSettings
|
||||
case waiting
|
||||
}
|
||||
|
||||
enum TalkProWaveformMode: Equatable {
|
||||
case level(Double)
|
||||
case inputSpeech
|
||||
case speaking
|
||||
case indeterminate
|
||||
case still
|
||||
}
|
||||
|
||||
struct TalkProState: Equatable {
|
||||
let gatewayConnected: Bool
|
||||
let isDemoMode: Bool
|
||||
let isEnabled: Bool
|
||||
let statusText: String
|
||||
let isConfigLoaded: Bool
|
||||
let isListening: Bool
|
||||
let isSpeaking: Bool
|
||||
let isUserSpeechDetected: Bool
|
||||
let permissionState: TalkGatewayPermissionState
|
||||
|
||||
private var normalizedStatus: String {
|
||||
self.statusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
var title: String {
|
||||
if self.isDemoMode { return "Demo mode only" }
|
||||
if !self.gatewayConnected { return "Gateway offline" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "Gateway permission required"
|
||||
case .requestingUpgrade:
|
||||
return "Requesting approval"
|
||||
case .upgradeRequested:
|
||||
return "Approval requested"
|
||||
case .apiKeyMissing:
|
||||
return "Voice API key missing"
|
||||
case .loadFailed:
|
||||
return "Voice config failed"
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !self.isConfigLoaded { return "Voice config unavailable" }
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.normalizedStatus.contains("connecting") { return "Connecting" }
|
||||
if self.normalizedStatus.contains("thinking") { return "Asking OpenClaw" }
|
||||
if self.isEnabled { return "Ready to talk" }
|
||||
return "Talk is off"
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
if self.isDemoMode { return .secondary }
|
||||
if !self.gatewayConnected { return .secondary }
|
||||
switch self.permissionState {
|
||||
case .requestFailed, .loadFailed:
|
||||
return OpenClawBrand.danger
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .apiKeyMissing:
|
||||
return OpenClawBrand.warn
|
||||
default:
|
||||
if !self.isConfigLoaded { return OpenClawBrand.warn }
|
||||
return self.isEnabled ? OpenClawBrand.ok : OpenClawBrand.accentHot
|
||||
}
|
||||
}
|
||||
|
||||
var primaryAction: TalkProPrimaryAction {
|
||||
if self.isDemoMode { return .waiting }
|
||||
if !self.gatewayConnected { return .openSettings }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return .enablePermission
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return .waiting
|
||||
case .apiKeyMissing, .loadFailed:
|
||||
return .openSettings
|
||||
default:
|
||||
return self.isEnabled ? .stop : .start
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtonTitle: String {
|
||||
switch self.primaryAction {
|
||||
case .start: "Start Talk"
|
||||
case .stop: "Stop Talk"
|
||||
case .enablePermission: "Enable Talk"
|
||||
case .openSettings: self.gatewayConnected ? "Open Voice Settings" : "Open Gateway Settings"
|
||||
case .waiting: self.isDemoMode ? "Demo Mode Only" : "Waiting for Approval"
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtonIcon: String {
|
||||
switch self.primaryAction {
|
||||
case .start: "play.fill"
|
||||
case .stop: "stop.fill"
|
||||
case .enablePermission: "key.fill"
|
||||
case .openSettings: "gearshape.fill"
|
||||
case .waiting: self.isDemoMode ? "lock.fill" : "hourglass"
|
||||
}
|
||||
}
|
||||
|
||||
var prefersPermissionCopy: Bool {
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .requestFailed:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func waveformMode(micLevel: Double) -> TalkProWaveformMode {
|
||||
if self.isDemoMode { return .still }
|
||||
if !self.gatewayConnected { return .still }
|
||||
switch self.permissionState {
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return .indeterminate
|
||||
case .missingScope, .requestFailed, .apiKeyMissing, .loadFailed:
|
||||
return .still
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !self.isConfigLoaded { return .still }
|
||||
if self.isSpeaking { return .speaking }
|
||||
if self.isListening, self.isUserSpeechDetected { return .inputSpeech }
|
||||
if self.isListening { return .level(micLevel) }
|
||||
if self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking") {
|
||||
return .indeterminate
|
||||
}
|
||||
return self.isEnabled ? .indeterminate : .still
|
||||
}
|
||||
}
|
||||
108
apps/ios/Sources/Design/TalkRuntimeIssueBanner.swift
Normal file
108
apps/ios/Sources/Design/TalkRuntimeIssueBanner.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct TalkRuntimeIssueBanner: View {
|
||||
let issue: TalkRuntimeIssue
|
||||
var onOpenSettings: (() -> Void)?
|
||||
var onShowDetails: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
OpenClawNoticeBanner(
|
||||
icon: self.iconName,
|
||||
title: self.issue.fallbackBannerTitle,
|
||||
message: self.issue.fallbackBannerMessage,
|
||||
ownerLabel: self.issue.fallbackBannerOwnerLabel,
|
||||
tint: self.tint,
|
||||
detail: .accent(self.issue.displayMessage),
|
||||
primaryActionTitle: "Open Settings",
|
||||
onPrimaryAction: self.onOpenSettings,
|
||||
secondaryActionTitle: "Details",
|
||||
onSecondaryAction: self.onShowDetails)
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
"exclamationmark.triangle.fill"
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
OpenClawBrand.warn
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkRuntimeIssueDetailsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let issue: TalkRuntimeIssue
|
||||
var onOpenSettings: (() -> Void)?
|
||||
|
||||
@State private var copyFeedback: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(self.issue.fallbackBannerTitle)
|
||||
.font(OpenClawType.title3SemiBold)
|
||||
Text(self.issue.fallbackBannerMessage)
|
||||
.font(OpenClawType.body)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.issue.displayMessage)
|
||||
.font(OpenClawType.footnoteSemiBold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section {
|
||||
Text(verbatim: self.issue.technicalDetails)
|
||||
.font(OpenClawType.monoFootnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
Button {
|
||||
UIPasteboard.general.string = self.issue.technicalDetails
|
||||
self.copyFeedback = "Copied diagnostics"
|
||||
} label: {
|
||||
Text("Copy diagnostics")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
} header: {
|
||||
Text("Technical details")
|
||||
.font(OpenClawType.captionSemiBold)
|
||||
}
|
||||
|
||||
if let copyFeedback {
|
||||
Section {
|
||||
Text(copyFeedback)
|
||||
.font(OpenClawType.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Talk fallback")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if let onOpenSettings {
|
||||
Button {
|
||||
self.dismiss()
|
||||
onOpenSettings()
|
||||
} label: {
|
||||
Text("Open Settings")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Text("Done")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
apps/ios/Sources/Design/TalkSiriWave.swift
Normal file
124
apps/ios/Sources/Design/TalkSiriWave.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import SwiftUI
|
||||
|
||||
/// iOS 9-style Siri waveform for the Talk screen, driven by the runtime mode.
|
||||
/// Math adapted from noahchalifour/swiftui-siri-waveform-view (MIT), as packaged
|
||||
/// by alfianlosari/SiriWaveView; redrawn with Canvas + TimelineView so lobes flow
|
||||
/// continuously instead of re-randomizing per power change.
|
||||
///
|
||||
/// State rendering: off = flat static grey; connecting/thinking = slow low
|
||||
/// breathing; listening = amplitude follows mic level; speech detected = full
|
||||
/// power; agent speaking = strong TTS-style pulse.
|
||||
struct TalkSiriWaveView: View {
|
||||
var mode: TalkProWaveformMode
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
private static let born = Date()
|
||||
|
||||
private static let monoColors: [Color] = [
|
||||
OpenClawBrand.accent,
|
||||
Color(red: 0.95, green: 0.45, blue: 0.30),
|
||||
Color(red: 0.45, green: 0.08, blue: 0.12),
|
||||
]
|
||||
private static let inactiveColors: [Color] = [
|
||||
Color(uiColor: .systemGray2),
|
||||
Color(uiColor: .systemGray3),
|
||||
Color(uiColor: .systemGray4),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
let frozen = self.reduceMotion || self.mode == .still
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: frozen)) { timeline in
|
||||
let time = frozen ? 0 : timeline.date.timeIntervalSince(Self.born)
|
||||
let power = Self.power(for: self.mode, time: time)
|
||||
Canvas { context, size in
|
||||
let midY = size.height / 2
|
||||
var line = Path()
|
||||
line.move(to: CGPoint(x: 0, y: midY))
|
||||
line.addLine(to: CGPoint(x: size.width, y: midY))
|
||||
context.stroke(line, with: .color(.secondary.opacity(0.30)), lineWidth: 1)
|
||||
|
||||
// Screen blend pops on dark; opacity overlap reads better on light.
|
||||
context.blendMode = self.colorScheme == .dark ? .screen : .normal
|
||||
let opacity = self.colorScheme == .dark ? 0.9 : 0.55
|
||||
for (index, color) in self.colors.enumerated() {
|
||||
let path = Self.wavePath(
|
||||
in: size,
|
||||
time: time,
|
||||
seed: Double(index) * 7.31,
|
||||
power: power)
|
||||
context.fill(path, with: .color(color.opacity(opacity)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(self.mode == .still ? 0.6 : 1.0)
|
||||
}
|
||||
|
||||
private var colors: [Color] {
|
||||
self.mode == .still ? Self.inactiveColors : Self.monoColors
|
||||
}
|
||||
|
||||
/// Per-state drive: thinking breathes low and slow, speaking pulses like
|
||||
/// TTS playback, listening follows the live mic level.
|
||||
private static func power(for mode: TalkProWaveformMode, time: Double) -> Double {
|
||||
switch mode {
|
||||
case .still:
|
||||
0.05
|
||||
case .indeterminate:
|
||||
0.16 + 0.10 * (0.5 + 0.5 * sin(time * 1.6))
|
||||
case let .level(level):
|
||||
0.30 + 0.65 * min(max(level, 0), 1)
|
||||
case .inputSpeech:
|
||||
0.95
|
||||
case .speaking:
|
||||
0.70 * (0.55 + 0.45 * abs(sin(time * 5.0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// One wave = max envelope of three drifting lobes, mirrored around the midline.
|
||||
private static func wavePath(in size: CGSize, time: Double, seed: Double, power: Double) -> Path {
|
||||
let midX = Double(size.width) / 2
|
||||
let midY = Double(size.height) / 2
|
||||
|
||||
// Lobe parameters oscillate smoothly so peaks sweep back and forth
|
||||
// across the line instead of scrolling off-screen.
|
||||
let lobes: [(A: Double, k: Double, t: Double)] = (0..<3).map { index in
|
||||
let f = Double(index)
|
||||
let amp = 0.30 + 0.70 * (0.5 + 0.5 * sin(time * (0.9 + 0.23 * f) + seed * 2.4 + f * 2.1))
|
||||
let k = 0.62 + 0.11 * f
|
||||
let t = 2.8 * sin(time * (0.45 + 0.17 * f) + seed + f * 1.9)
|
||||
return (A: amp, k: k, t: t)
|
||||
}
|
||||
|
||||
var upper: [CGPoint] = []
|
||||
var x = -midX
|
||||
while x <= midX {
|
||||
let graphX = x / (midX / 9.0)
|
||||
var y: Double = 0
|
||||
for lobe in lobes {
|
||||
let amplitude = lobe.A * midY * power
|
||||
y = max(y, Self.attenuatedSine(x: graphX, A: amplitude, k: lobe.k, t: lobe.t))
|
||||
}
|
||||
upper.append(CGPoint(x: midX + x, y: midY - y))
|
||||
x += 2
|
||||
}
|
||||
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 0, y: midY))
|
||||
path.addLines(upper)
|
||||
for point in upper.reversed() {
|
||||
path.addLine(to: CGPoint(x: point.x, y: 2 * midY - point.y))
|
||||
}
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
|
||||
/// |A·sin(kx − t)| shaped by the bell envelope g = (K/(K+(kx−t′)²))^K, K = 4.
|
||||
private static func attenuatedSine(x: Double, A: Double, k: Double, t: Double) -> Double {
|
||||
let sine = A * sin(k * x - t)
|
||||
let tPrime = t - .pi / 2
|
||||
let envelope = pow(4.0 / (4.0 + pow(k * x - tPrime, 2)), 4.0)
|
||||
return abs(sine * envelope)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user