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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View 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
}

View 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?
}

View 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)"
}
}

View 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"
}
}

View 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)
}
}

View 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)
}
}
}

View 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
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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
}
}

View 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)
}
}

View 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
}
}

View 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
}
}

View 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

View 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."
}
}
}

View 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
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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)
}
}

View 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()
}
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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

View 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

View 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)
}
}

View 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."
}
}

View 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 devices 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)
}
}
}

View 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

View 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
}
}

View 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)
}
}
}
}
}
}

View 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+(kxt)²))^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)
}
}