Files
openclaw/apps/ios/Sources/Design/AgentProNodesDestination.swift
Colin Johnson f6e51ff99a feat(ios): refresh pro UI and gateway flows (#87367)
Summary:
- Replace the legacy iOS shell with Pro Command, Chat, Agents, and Settings tabs.
- Wire iOS chat/session/settings/diagnostics and realtime Talk flows through gateway-backed APIs.
- Add gateway/session and shared chat coverage for the new iOS flow.

Verification:
- git diff --check
- node scripts/run-vitest.mjs src/gateway/server.sessions.create.test.ts src/gateway/talk-realtime-relay.test.ts
- swift test --filter ChatViewModelTests (apps/shared/OpenClawKit)
- xcodebuild build for Nimrod's iPhone succeeded; install succeeded; launch was blocked because the phone was locked

Known follow-up:
- Preserve traceLevel in sessions.create parent runtime inheritance and keep the changelog credit in the follow-up patch.
2026-05-28 17:23:26 +03:00

349 lines
14 KiB
Swift

import OpenClawProtocol
import SwiftUI
import UIKit
struct AgentProNodesDestination: View {
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.summaryCard
self.totalsCard
self.nodesList
}
.padding(.vertical, 18)
}
.refreshable {
await self.refresh()
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Nodes")
.navigationBarTitleDisplayMode(.inline)
}
private var summaryCard: some View {
ProCard {
HStack(spacing: 12) {
ProIconBadge(systemName: "display", color: self.instancesColor)
VStack(alignment: .leading, spacing: 3) {
Text("Nodes")
.font(.headline)
Text(self.instancesDetail)
.font(.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(.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 Nodes")
ProCard(padding: 0) {
let nodes = self.sortedPresenceEntries
if nodes.isEmpty {
self.emptyRow(
icon: "display",
title: self.gatewayConnected ? "No nodes connected" : "Nodes unavailable",
detail: self.gatewayConnected
? "The gateway did not report any system presence entries."
: "Connect a gateway to inspect connected nodes.")
.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) ?? "Node")
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(Self.presenceDetail(entry))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
if let meta = Self.presenceMeta(entry) {
Text(meta)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Text(Self.presenceState(entry))
.font(.caption2.weight(.semibold))
.foregroundStyle(Self.presenceColor(entry))
.lineLimit(1)
if showsChevron {
Image(systemName: "chevron.right")
.font(.caption2.weight(.bold))
.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) ?? "Node")
.font(.headline)
Text(Self.presenceDetail(entry))
.font(.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)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle(Self.presenceLabel(entry) ?? "Node")
.navigationBarTitleDisplayMode(.inline)
}
private func nodeDetailRow(_ title: String, value: String?) -> some View {
let normalized = Self.normalized(value) ?? "n/a"
return HStack(spacing: 10) {
Text(title)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(normalized)
.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(.subheadline)
.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(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(values, id: \.self) { value in
Text(value)
.font(.caption.monospaced())
.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(.caption2.weight(.medium))
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 10, 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(.subheadline.weight(.semibold))
Text(detail)
.font(.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)"
}
}