Improve iPad and iPhone control surfaces (#91557)

* feat(ios): expand iPad layout support

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: improve iPad and iPhone control surfaces

* fix: preserve workboard dispatch compatibility

* fix: keep Talk reachable on iPad

* fix: add universal iPad app icons

* fix: address ready-review iOS feedback

* fix: avoid workboard board id shadowing

* fix ios sidebar separators

---------

Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
Colin Johnson
2026-06-09 22:46:02 -04:00
committed by GitHub
parent a8d33f23a0
commit bf89552e67
50 changed files with 8836 additions and 402 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,7 @@ import SwiftUI
struct AgentProDreamingDestination: View {
@Environment(NodeAppModel.self) private var appModel
let headerLeadingAction: OpenClawSidebarHeaderAction?
let overview: AgentOverviewSnapshot?
let gatewayConnected: Bool
let overviewLoading: Bool
@@ -20,6 +21,7 @@ struct AgentProDreamingDestination: View {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.header
self.detailSummaryCard(
icon: "moon",
title: "Dreaming",
@@ -57,6 +59,23 @@ struct AgentProDreamingDestination: View {
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private var header: some View {
if let headerLeadingAction {
OpenClawAdaptiveHeaderRow(
title: "Dreaming",
subtitle: self.dreamingDetail,
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private enum DreamAction: String, CaseIterable, Identifiable {
case backfill
case repair

View File

@@ -3,6 +3,7 @@ import SwiftUI
import UIKit
struct AgentProNodesDestination: View {
let headerLeadingAction: OpenClawSidebarHeaderAction?
let overview: AgentOverviewSnapshot?
let gatewayConnected: Bool
let agentCount: Int
@@ -16,6 +17,7 @@ struct AgentProNodesDestination: View {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.header
self.summaryCard
self.totalsCard
self.nodesList
@@ -27,16 +29,33 @@ struct AgentProNodesDestination: View {
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Nodes")
.navigationTitle("Instances")
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private var header: some View {
if let headerLeadingAction {
OpenClawAdaptiveHeaderRow(
title: "Instances",
subtitle: self.instancesDetail,
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
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("Nodes")
Text("Instances")
.font(.headline)
Text(self.instancesDetail)
.font(.caption)
@@ -70,16 +89,16 @@ struct AgentProNodesDestination: View {
private var nodesList: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Connected Nodes")
ProSectionHeader(title: "Connected Instances")
ProCard(padding: 0) {
let nodes = self.sortedPresenceEntries
if nodes.isEmpty {
self.emptyRow(
icon: "display",
title: self.gatewayConnected ? "No nodes connected" : "Nodes unavailable",
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 nodes.")
: "Connect a gateway to inspect connected instances.")
.padding(14)
} else {
VStack(spacing: 0) {
@@ -114,7 +133,7 @@ struct AgentProNodesDestination: 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")
Text(Self.presenceLabel(entry) ?? "Instance")
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(Self.presenceDetail(entry))
@@ -153,7 +172,7 @@ struct AgentProNodesDestination: View {
HStack(spacing: 12) {
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
VStack(alignment: .leading, spacing: 3) {
Text(Self.presenceLabel(entry) ?? "Node")
Text(Self.presenceLabel(entry) ?? "Instance")
.font(.headline)
Text(Self.presenceDetail(entry))
.font(.caption)
@@ -192,7 +211,7 @@ struct AgentProNodesDestination: View {
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle(Self.presenceLabel(entry) ?? "Node")
.navigationTitle(Self.presenceLabel(entry) ?? "Instance")
.navigationBarTitleDisplayMode(.inline)
}

View File

@@ -6,10 +6,12 @@ extension AgentProTab {
@ViewBuilder
func destination(for route: AgentRoute) -> some View {
switch route {
case .agents:
self.agentsDestination
case .skills:
self.skillsDestination
case .nodes:
self.nodesDestination
case .instances:
self.instancesDestination
case .cron:
self.cronDestination
case .usage:
@@ -19,6 +21,26 @@ extension AgentProTab {
}
}
var agentsDestination: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.rosterHeader
self.agentFilters
self.agentsSection
}
.padding(.vertical, 18)
}
.refreshable {
await self.refreshOverview(force: true)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Agents")
.navigationBarTitleDisplayMode(.inline)
}
var skillsDestination: some View {
ZStack {
OpenClawProBackground()
@@ -46,8 +68,9 @@ extension AgentProTab {
.navigationBarTitleDisplayMode(.inline)
}
var nodesDestination: some View {
var instancesDestination: some View {
AgentProNodesDestination(
headerLeadingAction: self.directHeaderLeadingAction(for: .instances),
overview: self.overview,
gatewayConnected: self.gatewayConnected,
agentCount: self.appModel.gatewayAgents.count,
@@ -64,6 +87,10 @@ extension AgentProTab {
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",
@@ -89,6 +116,10 @@ extension AgentProTab {
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",
@@ -111,6 +142,7 @@ extension AgentProTab {
var dreamingDestination: some View {
AgentProDreamingDestination(
headerLeadingAction: self.directHeaderLeadingAction(for: .dreaming),
overview: self.overview,
gatewayConnected: self.gatewayConnected,
overviewLoading: self.overviewLoading,
@@ -122,6 +154,27 @@ extension AgentProTab {
})
}
@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: .title3.weight(.semibold),
subtitleFont: .callout)
{
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,

View File

@@ -5,18 +5,19 @@ import SwiftUI
extension AgentProTab {
var rosterHeader: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text("Agents")
.font(.system(size: 28, weight: .bold))
Text("\(self.sortedAgents.count) total")
.font(.subheadline)
.foregroundStyle(.secondary)
OpenClawAdaptiveHeaderRow(
title: self.headerTitle,
subtitle: "\(self.sortedAgents.count) total",
titleFont: .system(size: 28, weight: .bold),
subtitleFont: .subheadline,
subtitleLineLimit: 1)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
Spacer(minLength: 8)
} accessory: {
HStack(spacing: 10) {
self.gatewayPillButton
self.headerIconButton(
systemName: "magnifyingglass",
label: "Search agents",
@@ -56,6 +57,19 @@ extension AgentProTab {
.padding(.top, 6)
}
@ViewBuilder
private var gatewayPillButton: some View {
if let openSettings {
Button(action: openSettings) {
OpenClawGatewayCompactPill()
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
OpenClawGatewayCompactPill()
}
}
var agentFilters: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
@@ -140,7 +154,7 @@ extension AgentProTab {
value: self.instancesValue,
detail: self.instancesDetail,
color: self.instancesColor,
route: .nodes)
route: .instances)
self.metricTile(
icon: "clock.arrow.circlepath",
title: "Cron",

View File

@@ -6,6 +6,12 @@ struct AgentProTab: View {
@Environment(NodeAppModel.self) var appModel
@Environment(\.colorScheme) var colorScheme
@Environment(\.scenePhase) var scenePhase
let initialRoute: AgentRoute?
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
@@ -31,8 +37,9 @@ struct AgentProTab: View {
@State var cronActionStatusText: String?
enum AgentRoute: Hashable {
case agents
case skills
case nodes
case instances
case cron
case usage
case dreaming
@@ -119,8 +126,42 @@ struct AgentProTab: View {
}
}
init(
initialRoute: AgentRoute? = nil,
directRoute: AgentRoute? = nil,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
headerTitle: String = "Agents",
openSettings: (() -> Void)? = nil)
{
self.initialRoute = initialRoute
self.directRoute = directRoute
self.headerLeadingAction = headerLeadingAction
self.headerTitle = headerTitle
self.openSettings = openSettings
}
var body: some View {
NavigationStack {
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 {
@@ -143,15 +184,22 @@ struct AgentProTab: View {
self.destination(for: route)
}
}
.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
}
.onAppear {
self.applyInitialRouteIfNeeded()
}
}
private func directDestination(for route: AgentRoute) -> some View {
self.destination(for: route)
.toolbar(
self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden,
for: .navigationBar)
}
private func applyInitialRouteIfNeeded() {
guard self.directRoute == nil else { return }
guard let initialRoute else { return }
guard self.navigationPath != [initialRoute] else { return }
self.navigationPath = [initialRoute]
}
}

View File

@@ -7,6 +7,25 @@ struct ChatProTab: View {
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel: OpenClawChatViewModel?
@State private var viewModelUsesAppleReviewDemoTransport = false
let headerLeadingAction: OpenClawSidebarHeaderAction?
let headerTitle: String?
let headerSubtitle: String?
let showsAgentBadge: Bool
let openSettings: (() -> Void)?
init(
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
headerTitle: String? = nil,
headerSubtitle: String? = nil,
showsAgentBadge: Bool = true,
openSettings: (() -> Void)? = nil)
{
self.headerLeadingAction = headerLeadingAction
self.headerTitle = headerTitle
self.headerSubtitle = headerSubtitle
self.showsAgentBadge = showsAgentBadge
self.openSettings = openSettings
}
var body: some View {
NavigationStack {
@@ -67,7 +86,30 @@ struct ChatProTab: View {
}
private var header: some View {
HStack(spacing: 11) {
OpenClawAdaptiveHeaderRow(
title: self.headerDisplayTitle,
subtitle: self.headerDisplaySubtitle,
titleFont: .headline.weight(.semibold),
subtitleFont: .caption,
subtitleLineLimit: 1)
{
HStack(spacing: 11) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
self.headerIdentityBadge
}
} accessory: {
self.connectionPillButton
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 8)
.padding(.bottom, 4)
}
@ViewBuilder
private var headerIdentityBadge: some View {
if self.showsAgentBadge {
Text(self.agentBadge)
.font(.system(size: self.agentBadge.count > 2 ? 13 : 16, weight: .bold, design: .rounded))
.foregroundStyle(.white)
@@ -86,24 +128,9 @@ struct ChatProTab: View {
endPoint: .bottomTrailing)))
.overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1))
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: 10, y: 5)
VStack(alignment: .leading, spacing: 1) {
Text(self.agentDisplayName)
.font(.headline.weight(.semibold))
.lineLimit(1)
Text("AI Assistant")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
self.connectionPill
} else {
ProIconBadge(systemName: "bubble.left", color: OpenClawBrand.accent)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 8)
.padding(.bottom, 4)
}
private func syncChatViewModel() {
@@ -162,37 +189,93 @@ struct ChatProTab: View {
?? "main"
}
@ViewBuilder
private var connectionPillButton: some View {
if let openSettings {
Button(action: openSettings) {
self.connectionPill
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
self.connectionPill
}
}
private var connectionPill: some View {
HStack(spacing: 6) {
ProStatusDot(color: self.gatewayConnected ? OpenClawBrand.ok : .orange)
Text(self.gatewayConnected ? "Connected" : "Connecting")
ProStatusDot(color: self.gatewayPillColor)
Text(Self.gatewayPillTitle(state: self.gatewayDisplayState, isGatewayUsable: self.gatewayConnected))
.font(.caption.weight(.semibold))
.lineLimit(1)
}
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .orange)
.foregroundStyle(self.gatewayPillColor)
.padding(.horizontal, 10)
.frame(height: 30)
.background {
Capsule()
.fill((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.11))
.fill(self.gatewayPillColor.opacity(0.11))
}
.overlay {
Capsule()
.strokeBorder((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.16), lineWidth: 1)
.strokeBorder(self.gatewayPillColor.opacity(0.16), lineWidth: 1)
}
}
private var gatewayConnected: Bool {
guard GatewayStatusBuilder.build(appModel: self.appModel) == .connected else {
guard self.gatewayDisplayState == .connected else {
return false
}
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
}
private var gatewayDisplayState: GatewayDisplayState {
GatewayStatusBuilder.build(appModel: self.appModel)
}
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)
}
private var headerDisplaySubtitle: String {
self.normalized(self.headerSubtitle) ?? "AI Assistant"
}
nonisolated static func defaultHeaderTitle(showsAgentBadge: Bool, agentDisplayName: String) -> String {
showsAgentBadge ? agentDisplayName : "Chat"
}
private var chatUserAccent: Color {
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
}

View File

@@ -23,7 +23,7 @@ struct CommandPanel<Content: View>: View {
tint: self.tint,
isProminent: self.isProminent,
padding: self.padding,
radius: 12)
radius: OpenClawProMetric.cardRadius)
{
self.content
}
@@ -34,40 +34,15 @@ struct CommandControlBackground: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
LinearGradient(
colors: self.colorScheme == .dark ? self.darkColors : self.lightColors,
startPoint: .top,
endPoint: .bottom)
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
.overlay(alignment: .top) {
if self.colorScheme == .light {
LinearGradient(
colors: [
Color.white.opacity(0.34),
Color.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
.frame(height: 260)
Color.white.opacity(0.20)
.frame(height: 140)
}
}
.ignoresSafeArea()
}
private var darkColors: [Color] {
[
Color(red: 12 / 255, green: 13 / 255, blue: 15 / 255),
Color(red: 7 / 255, green: 8 / 255, blue: 10 / 255),
Color(red: 4 / 255, green: 5 / 255, blue: 6 / 255),
]
}
private var lightColors: [Color] {
[
Color(red: 247 / 255, green: 248 / 255, blue: 249 / 255),
Color(red: 251 / 255, green: 252 / 255, blue: 253 / 255),
.white,
]
}
}
struct CommandSessionRow: View {
@@ -114,12 +89,12 @@ struct CommandSessionRow: View {
}
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
.padding(.vertical, 8)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.fill(self.rowFill)
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.strokeBorder(self.rowBorder, lineWidth: 1)
}
}
@@ -136,11 +111,11 @@ struct CommandSessionRow: View {
}
private var rowFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
}
private var rowBorder: Color {
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
}
}
@@ -154,21 +129,21 @@ struct CommandViewMoreRow: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.fill(self.rowFill)
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.strokeBorder(self.rowBorder, lineWidth: 1)
}
}
}
private var rowFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
}
private var rowBorder: Color {
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
}
}
@@ -199,13 +174,13 @@ struct CommandEmptyStateRow: View {
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 9)
.padding(.vertical, 8)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.black.opacity(0.06))
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.fill(Color(uiColor: .systemBackground))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.primary.opacity(0.055), lineWidth: 1)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.strokeBorder(Color(uiColor: .separator).opacity(0.22), lineWidth: 1)
}
}
}

View File

@@ -2,13 +2,17 @@ import OpenClawChatUI
import SwiftUI
struct CommandCenterTab: View {
fileprivate static let recentSessionsFetchLimit = 200
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 headerTitle: String = "OpenClaw"
var headerLeadingAction: OpenClawSidebarHeaderAction?
var showsHeaderMark: Bool = true
var openChat: () -> Void
var openSettings: () -> Void
@@ -31,20 +35,37 @@ struct CommandCenterTab: View {
var body: some View {
NavigationStack {
ZStack {
CommandControlBackground()
self.commandAmbientOverlay
ScrollView {
VStack(alignment: .leading, spacing: 10) {
self.header
self.gatewayCard
self.defaultChatSessionSection
self.recentSessions
GeometryReader { geometry in
ZStack {
CommandControlBackground()
self.commandAmbientOverlay
ScrollView {
VStack(alignment: .leading, spacing: 14) {
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)
}
.padding(.top, 16)
.padding(.bottom, 18)
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationBarHidden(true)
}
@@ -53,12 +74,47 @@ struct CommandCenterTab: View {
}
}
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 {
HStack(alignment: .center, spacing: 11) {
OpenClawProMark(size: 31, shadowRadius: 9)
Text("OpenClaw")
.font(.system(size: 27, weight: .bold, design: .rounded))
Spacer()
OpenClawAdaptiveHeaderRow(
title: self.headerTitle,
subtitle: self.gatewaySubtitle,
titleFont: .title3.weight(.semibold),
subtitleFont: .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) {
ProCapsule(
title: self.gatewayStateText,
color: self.gatewayStatusColor,
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
}
.buttonStyle(.plain)
.accessibilityLabel("Gateway \(self.gatewayStateText)")
.accessibilityHint("Opens Settings / Gateway")
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@@ -86,7 +142,7 @@ struct CommandCenterTab: View {
title: "Gateway",
value: self.gatewayStateText,
color: self.gatewayStatusColor,
icon: self.gatewayConnected ? "hourglass" : "wifi.slash")
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
HStack(spacing: 0) {
self.gatewayFact(
@@ -160,7 +216,6 @@ struct CommandCenterTab: View {
.buttonStyle(.plain)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var recentSessions: some View {
@@ -200,7 +255,6 @@ struct CommandCenterTab: View {
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func cardHeader(
@@ -213,7 +267,8 @@ struct CommandCenterTab: View {
{
HStack(spacing: 8) {
Text(title)
.font(.subheadline.weight(.bold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
if let badgeValue {
Text(badgeValue)
.font(.caption2.weight(.bold))
@@ -403,7 +458,7 @@ struct CommandCenterTab: View {
return result
}
fileprivate static func sessionWorkItem(
static func sessionWorkItem(
for session: OpenClawChatSessionEntry,
currentSessionKey: String) -> WorkItem
{
@@ -558,14 +613,20 @@ struct CommandCenterTab: View {
}
}
private struct CommandSessionsScreen: View {
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 openChat: () -> Void
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, openChat: @escaping () -> Void) {
self.headerLeadingAction = headerLeadingAction
self.openChat = openChat
}
var body: some View {
ZStack {
CommandControlBackground()
@@ -587,12 +648,18 @@ private struct CommandSessionsScreen: View {
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Sessions")
.font(.system(size: 27, weight: .bold, design: .rounded))
Text(self.headerDetail)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
HStack(alignment: .top, spacing: 12) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
VStack(alignment: .leading, spacing: 4) {
Text("Sessions")
.font(.system(size: 27, weight: .bold, design: .rounded))
Text(self.headerDetail)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}

View File

@@ -0,0 +1,244 @@
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 openChat: () -> Void
let openSettings: () -> Void
init(
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
openChat: @escaping () -> Void,
openSettings: @escaping () -> Void)
{
self.headerLeadingAction = headerLeadingAction
self.openChat = openChat
self.openSettings = openSettings
}
var body: some View {
IPadSidebarScreenChrome(
title: "Activity",
subtitle: "Live device and gateway activity.",
headerLeadingAction: self.headerLeadingAction,
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.accent),
ProMetric(
icon: "bubble.left.and.text.bubble.right",
title: "Sessions",
value: self.isLoading ? "..." : "\(self.sessionRows.count)",
color: OpenClawBrand.accentHot),
]
}
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.accent,
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.accent,
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.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
}
private var sessionsMode: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
}
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: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
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(.headline)
Text("Tap for detail, swipe or long-press for card actions.")
.font(.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(.headline)
Text("Tap for detail, swipe or long-press for proposal actions.")
.font(.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(.caption.weight(.semibold))
.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(.caption.weight(.semibold))
.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(.subheadline.weight(.semibold))
.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(.headline)
Text("Wide layout with populated, empty, held, and custom proposal lanes.")
.font(.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,71 @@
import SwiftUI
struct IPadSidebarScreenChrome<Content: View>: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
let title: String
let subtitle: String
let headerLeadingAction: OpenClawSidebarHeaderAction?
let gatewayAction: (() -> Void)?
@ViewBuilder var content: Content
init(
title: String,
subtitle: String,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
gatewayAction: (() -> Void)? = nil,
@ViewBuilder content: () -> Content)
{
self.title = title
self.subtitle = subtitle
self.headerLeadingAction = headerLeadingAction
self.gatewayAction = gatewayAction
self.content = content()
}
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
OpenClawAdaptiveHeaderRow(
title: self.title,
subtitle: self.subtitle,
titleFont: self.isCompactHeight ? .headline.weight(.semibold) : .title2.weight(.semibold),
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)
}
.safeAreaPadding(.bottom, self.bottomScrollInset)
}
}
private var isCompactHeight: Bool {
self.verticalSizeClass == .compact
}
@ViewBuilder
private var gatewayPill: some View {
if let gatewayAction {
Button(action: gatewayAction) {
OpenClawGatewayCompactPill()
}
.buttonStyle(.plain)
.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,129 @@
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 gatewayAction: (() -> Void)?
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
self.headerLeadingAction = headerLeadingAction
self.gatewayAction = gatewayAction
}
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.headerCard
self.linkCard
self.versionCard
}
.padding(.vertical, 18)
}
}
.navigationTitle("Docs")
.navigationBarTitleDisplayMode(.inline)
}
private var headerCard: some View {
ProCard(radius: OpenClawProMetric.cardRadius) {
OpenClawAdaptiveHeaderRow(
title: "Docs",
subtitle: "Gateway setup, pairing, channels, and mobile node reference.",
titleFont: .headline,
subtitleFont: .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()
}
.buttonStyle(.plain)
.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 var versionCard: some View {
ProCard(radius: OpenClawProMetric.cardRadius) {
HStack(spacing: 10) {
Text("Version")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text("v\(DeviceInfoHelper.openClawVersionString())")
.font(.caption.weight(.bold))
.foregroundStyle(.primary)
.textSelection(.enabled)
}
}
.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(.subheadline.weight(.semibold))
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: "arrow.up.right")
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}

View File

@@ -1,33 +1,23 @@
import SwiftUI
enum OpenClawProMetric {
static let pagePadding: CGFloat = 20
static let cardRadius: CGFloat = 14
static let controlRadius: CGFloat = 12
static let pagePadding: CGFloat = 18
static let cardRadius: CGFloat = 10
static let controlRadius: CGFloat = 8
static let bottomScrollInset: CGFloat = 96
static let heroRadius: CGFloat = 22
static let heroRadius: CGFloat = 12
}
struct OpenClawProBackground: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
LinearGradient(
colors: OpenClawBrand.canvasColors(for: self.colorScheme),
startPoint: .top,
endPoint: .bottom)
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
.ignoresSafeArea()
.overlay(alignment: .top) {
if self.colorScheme == .light {
LinearGradient(
colors: [
OpenClawBrand.accent.opacity(0.05),
OpenClawBrand.accent.opacity(0.02),
.clear,
],
startPoint: .topTrailing,
endPoint: .bottomLeading)
.frame(height: 620)
Color.white.opacity(0.22)
.frame(height: 140)
.ignoresSafeArea()
}
}
@@ -66,7 +56,7 @@ struct ProSectionHeader: View {
struct ProCard<Content: View>: View {
var tint: Color?
var isProminent: Bool = false
var padding: CGFloat = 14
var padding: CGFloat = 12
var radius: CGFloat = OpenClawProMetric.cardRadius
@ViewBuilder var content: Content
@@ -91,78 +81,39 @@ private struct ProPanelBackground: View {
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
shape
.fill(self.fill)
.overlay {
ProPanelTexture()
.opacity(self.colorScheme == .dark ? 0.22 : 0.08)
.clipShape(shape)
}
.overlay {
shape.strokeBorder(self.borderStyle, lineWidth: 1)
}
.overlay {
shape
.strokeBorder(Color.black.opacity(self.colorScheme == .dark ? 0.40 : 0.055), lineWidth: 0.7)
.padding(1)
}
.overlay(alignment: .top) {
shape
.strokeBorder(Color.white.opacity(self.colorScheme == .dark ? 0.07 : 0.36), lineWidth: 0.7)
.mask(alignment: .top) {
Rectangle().frame(height: 28)
}
if self.isProminent {
shape.strokeBorder(
OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.12 : 0.07),
lineWidth: 1)
.padding(1)
}
}
}
private var fill: AnyShapeStyle {
if self.colorScheme == .dark {
let base = self.isProminent
? Color(red: 15 / 255, green: 17 / 255, blue: 19 / 255)
: Color(red: 10 / 255, green: 12 / 255, blue: 14 / 255)
return AnyShapeStyle(base)
let base = self.isProminent
? Color(uiColor: .systemBackground)
: Color(uiColor: .secondarySystemGroupedBackground)
if let tint {
let gradient = LinearGradient(
colors: [
base,
tint.opacity(self.colorScheme == .dark ? 0.08 : 0.045),
base,
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
return AnyShapeStyle(gradient)
}
let gradient = LinearGradient(
colors: [
Color.white.opacity(0.98),
(self.tint ?? Color.white).opacity(self.tint == nil ? 0.92 : 0.12),
Color(red: 246 / 255, green: 247 / 255, blue: 249 / 255),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
return AnyShapeStyle(gradient)
return AnyShapeStyle(base)
}
private var borderStyle: AnyShapeStyle {
if self.colorScheme == .dark {
return AnyShapeStyle(Color.white.opacity(self.isProminent ? 0.15 : 0.11))
}
let gradient = LinearGradient(
colors: [
Color.white.opacity(0.72),
(self.tint ?? OpenClawBrand.accent).opacity(0.10),
Color.black.opacity(0.08),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
return AnyShapeStyle(gradient)
}
}
private struct ProPanelTexture: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Canvas { context, size in
let color = self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.08)
for y in stride(from: 2.0, through: size.height, by: 6.5) {
let offset = Int(y / 6.5).isMultiple(of: 2) ? 0.0 : 3.25
for x in stride(from: 2.0 + offset, through: size.width, by: 6.5) {
let dot = CGRect(x: x, y: y, width: 0.7, height: 0.7)
context.fill(Path(ellipseIn: dot), with: .color(color))
}
}
}
AnyShapeStyle(Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.26 : 0.30))
}
}
@@ -251,9 +202,9 @@ private struct ProPanelSurfaceModifier: ViewModifier {
}
.modifier(ProLightGlassModifier(radius: self.radius))
.shadow(
color: self.colorScheme == .dark ? .black.opacity(0.60) : .black.opacity(0.045),
radius: self.isProminent ? 20 : 12,
y: self.isProminent ? 10 : 6)
color: self.colorScheme == .dark ? .black.opacity(0.22) : .black.opacity(0.028),
radius: self.isProminent ? 9 : 4,
y: self.isProminent ? 4 : 1)
}
}
@@ -263,16 +214,160 @@ struct ProIconBadge: View {
var body: some View {
Image(systemName: self.systemName)
.font(.subheadline.weight(.semibold))
.font(.caption.weight(.semibold))
.foregroundStyle(self.color)
.frame(width: 34, height: 34)
.frame(width: 30, height: 30)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: 8, 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
}
init(action: @escaping () -> Void) {
self.headerAction = OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Show Sidebar",
action: action)
}
var body: some View {
let button = Button(action: self.headerAction.action) {
Image(systemName: self.headerAction.systemName)
.font(.system(size: 16, weight: .semibold))
.frame(width: 38, height: 38)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(OpenClawBrand.accent)
.accessibilityLabel(self.headerAction.accessibilityLabel)
if let accessibilityIdentifier = self.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 OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View {
let title: String
let subtitle: String
var titleFont: Font = .title3.weight(.semibold)
var subtitleFont: Font = .subheadline
var subtitleLineLimit: Int? = 2
@ViewBuilder let leading: Leading
@ViewBuilder let accessory: Accessory
init(
title: String,
subtitle: String,
titleFont: Font = .title3.weight(.semibold),
subtitleFont: Font = .subheadline,
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)
Text(self.subtitle)
.font(self.subtitleFont)
.foregroundStyle(.secondary)
.lineLimit(self.subtitleLineLimit)
.fixedSize(horizontal: false, vertical: true)
}
}
}
struct ProStatusDot: View {
var color: Color
@@ -280,7 +375,6 @@ struct ProStatusDot: View {
Circle()
.fill(self.color)
.frame(width: 8, height: 8)
.shadow(color: self.color.opacity(0.35), radius: 4)
}
}
@@ -312,7 +406,7 @@ struct OpenClawProMark: View {
.resizable()
.scaledToFit()
.frame(width: self.size, height: self.size)
.shadow(color: OpenClawBrand.accent.opacity(0.28), radius: self.shadowRadius, y: self.shadowRadius / 2)
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: self.shadowRadius, y: self.shadowRadius / 3)
.accessibilityLabel("OpenClaw")
}
}
@@ -390,7 +484,10 @@ struct ProCapsule: View {
}
Text(self.title)
.font(.caption.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.78)
}
.fixedSize(horizontal: true, vertical: false)
.foregroundStyle(self.color)
.padding(.horizontal, 10)
.padding(.vertical, 7)
@@ -405,6 +502,57 @@ struct ProCapsule: View {
}
}
struct OpenClawGatewayCompactPill: View {
@Environment(NodeAppModel.self) private var appModel
var body: some View {
ProCapsule(
title: self.title,
color: self.color,
icon: self.icon)
.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 color: Color {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
OpenClawBrand.ok
case .connecting:
OpenClawBrand.accent
case .error:
OpenClawBrand.warn
case .disconnected:
.secondary
}
}
private var icon: String {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
"checkmark.circle.fill"
case .connecting:
"arrow.triangle.2.circlepath"
case .error:
"exclamationmark.triangle.fill"
case .disconnected:
"wifi.slash"
}
}
}
struct ProSegmentedControl: View {
@Environment(\.colorScheme) private var colorScheme
let labels: [String]
@@ -531,28 +679,120 @@ struct ProMetricTile: View {
}
}
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(.subheadline.weight(.semibold))
if let value {
Text(value)
.font(.caption2.weight(.bold))
.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 ?? self.actionTitle ?? self.title)
.disabled(self.isActionDisabled)
} else if let actionTitle {
Button(actionTitle, action: action)
.font(.caption.weight(.semibold))
.disabled(self.isActionDisabled)
}
}
}
}
struct ProStatusRow: View {
let icon: String
let title: String
let detail: String
let value: String
let value: String?
let color: Color
var actionTitle: String?
var action: (() -> Void)?
var body: some View {
HStack(alignment: .center, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: self.icon, color: self.color)
VStack(alignment: .leading, spacing: 4) {
Text(self.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.lineLimit(2)
}
Spacer(minLength: 8)
ProValuePill(value: self.value, color: self.color)
VStack(alignment: .trailing, spacing: 6) {
if let value {
ProValuePill(value: value, color: self.color)
}
if let actionTitle, let action {
Button(actionTitle, action: action)
.font(.caption.weight(.semibold))
.buttonStyle(.bordered)
.controlSize(.mini)
}
}
}
.padding(.vertical, 11)
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
}

View File

@@ -0,0 +1,417 @@
import OpenClawProtocol
import SwiftUI
struct RootTabsPhoneControlHub: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var navigationPath: [RootTabs.SidebarDestination] = []
@State private var didApplyInitialDestination = false
let groups: [RootTabs.SidebarGroup]
let initialDestination: RootTabs.SidebarDestination?
let openRootDestination: (RootTabs.SidebarDestination) -> Void
var body: some View {
NavigationStack(path: self.$navigationPath) {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
self.headerCard
ForEach(self.groups) { group in
self.groupSection(group)
}
self.versionFooter
}
.padding(.vertical, self.isCompactHeight ? 10 : 16)
}
.safeAreaPadding(.bottom, self.bottomScrollInset)
}
.navigationTitle("Control")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: RootTabs.SidebarDestination.self) { destination in
self.detail(for: destination)
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
}
.onAppear {
self.applyInitialDestinationIfNeeded()
}
}
}
@ViewBuilder
private var headerCard: some View {
if self.isCompactHeight {
ProCard(padding: 8, radius: OpenClawProMetric.cardRadius) {
HStack(spacing: 12) {
OpenClawProMark(size: 24, shadowRadius: 3)
VStack(alignment: .leading, spacing: 3) {
Text(self.sidebarActiveAgentTitle)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.gatewayDisplayLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
} else {
ProCard(radius: OpenClawProMetric.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
OpenClawProMark(size: 32, shadowRadius: 4)
VStack(alignment: .leading, spacing: 3) {
Text(self.sidebarActiveAgentTitle)
.font(.headline)
.lineLimit(1)
Text(self.gatewayDisplayLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
}
self.gatewayActionRow
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private var gatewayActionRow: some View {
Button {
self.openRootDestination(.gateway)
} label: {
HStack(spacing: 10) {
ProStatusDot(color: self.gatewayStateColor)
VStack(alignment: .leading, spacing: 2) {
Text(self.gatewayStateText)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(self.gatewayDisplayLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
Text(self.gatewayActionTitle)
.font(.caption.weight(.semibold))
.foregroundStyle(OpenClawBrand.accent)
Image(systemName: "chevron.right")
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
}
.padding(10)
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("Gateway \(self.gatewayStateText)")
.accessibilityHint("Opens Settings / Gateway")
}
private func groupSection(_ group: RootTabs.SidebarGroup) -> some View {
VStack(alignment: .leading, spacing: self.isCompactHeight ? 6 : 8) {
ProSectionHeader(title: group.title.capitalized)
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ForEach(Array(group.destinations.enumerated()), id: \.element.id) { index, destination in
if index > 0 {
Divider().padding(.leading, 58)
}
self.destinationRow(destination)
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View {
if self.opensRootTab(destination) {
Button {
self.openRootDestination(destination)
} label: {
self.rowLabel(destination)
}
.buttonStyle(.plain)
} else {
Button {
self.navigationPath.append(destination)
} label: {
self.rowLabel(destination)
}
.buttonStyle(.plain)
}
}
private func rowLabel(_ destination: RootTabs.SidebarDestination) -> some View {
HStack(alignment: .center, spacing: 12) {
ProIconBadge(systemName: destination.systemImage, color: self.color(for: destination))
VStack(alignment: .leading, spacing: 3) {
Text(destination.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(destination.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
}
.padding(.vertical, self.isCompactHeight ? 8 : 10)
.padding(.horizontal, 14)
.contentShape(Rectangle())
}
private var versionFooter: some View {
ProCard(radius: OpenClawProMetric.cardRadius) {
HStack {
Spacer()
Text("v\(DeviceInfoHelper.openClawVersionString())")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private func detail(for destination: RootTabs.SidebarDestination) -> some View {
switch destination {
case .chat, .talk, .agents, .gateway:
EmptyView()
case .overview:
CommandCenterTab(
headerTitle: "Overview",
headerLeadingAction: self.phoneDetailBackAction,
showsHeaderMark: false,
openChat: { self.openRootDestination(.chat) },
openSettings: { self.openRootDestination(.gateway) })
case .activity:
IPadActivityScreen(
headerLeadingAction: self.phoneDetailBackAction,
openChat: { self.openRootDestination(.chat) },
openSettings: { self.openRootDestination(.gateway) })
case .workboard:
IPadWorkboardScreen(
headerLeadingAction: self.phoneDetailBackAction,
openChat: { self.openRootDestination(.chat) },
openSettings: { self.openRootDestination(.gateway) })
case .skillWorkshop:
IPadSkillWorkshopScreen(
headerLeadingAction: self.phoneDetailBackAction,
openSettings: { self.openRootDestination(.gateway) })
case .instances:
AgentProTab(
directRoute: .instances,
headerLeadingAction: self.phoneDetailBackAction,
headerTitle: "Instances",
openSettings: { self.openRootDestination(.gateway) })
case .sessions:
CommandSessionsScreen(
headerLeadingAction: self.phoneDetailBackAction,
openChat: { self.openRootDestination(.chat) })
case .dreaming:
AgentProTab(
directRoute: .dreaming,
headerLeadingAction: self.phoneDetailBackAction,
headerTitle: "Dreaming",
openSettings: { self.openRootDestination(.gateway) })
case .usage:
AgentProTab(
directRoute: .usage,
headerLeadingAction: self.phoneDetailBackAction,
headerTitle: "Usage",
openSettings: { self.openRootDestination(.gateway) })
case .cron:
AgentProTab(
directRoute: .cron,
headerLeadingAction: self.phoneDetailBackAction,
headerTitle: "Cron Jobs",
openSettings: { self.openRootDestination(.gateway) })
case .docs:
OpenClawDocsScreen(
headerLeadingAction: self.phoneDetailBackAction,
gatewayAction: { self.openRootDestination(.gateway) })
case .settings:
EmptyView()
}
}
private var phoneDetailBackAction: OpenClawSidebarHeaderAction {
OpenClawSidebarHeaderAction(
systemName: "chevron.left",
accessibilityLabel: "Back to Control",
accessibilityIdentifier: "OpenClawPhoneDetailBackButton",
action: { self.popPhoneDetail() })
}
private func popPhoneDetail() {
guard !self.navigationPath.isEmpty else { return }
self.navigationPath.removeLast()
}
private func opensRootTab(_ destination: RootTabs.SidebarDestination) -> Bool {
RootTabs.shouldOpenRootTabFromPhoneHub(destination)
}
private func applyInitialDestinationIfNeeded() {
guard !self.didApplyInitialDestination else { return }
self.didApplyInitialDestination = true
guard let initialDestination, initialDestination != .overview else { return }
if self.opensRootTab(initialDestination) {
self.openRootDestination(initialDestination)
} else {
self.navigationPath = [initialDestination]
}
}
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 gatewayDisplayLabel: String {
self.normalized(self.appModel.gatewayServerName)
?? self.normalized(self.appModel.gatewayRemoteAddress)
?? self.appModel.gatewayDisplayStatusText
}
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 var gatewayActionTitle: String {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
"Manage"
case .connecting:
"Details"
case .error:
"Fix"
case .disconnected:
"Connect"
}
}
private var isCompactHeight: Bool {
self.verticalSizeClass == .compact
}
private var bottomScrollInset: CGFloat {
Self.bottomScrollInset(verticalSizeClass: self.verticalSizeClass)
}
static func bottomScrollInset(verticalSizeClass: UserInterfaceSizeClass?) -> CGFloat {
verticalSizeClass == .compact ? 72 : 112
}
private func color(for destination: RootTabs.SidebarDestination) -> Color {
switch destination {
case .chat, .talk, .overview, .gateway:
OpenClawBrand.accent
case .instances:
Color.secondary
case .activity, .usage, .docs:
OpenClawBrand.accentHot
case .agents, .workboard, .skillWorkshop, .sessions, .dreaming, .cron, .settings:
OpenClawBrand.ok
}
}
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,
openRootDestination: { _ in })
.environment(appModel)
}
}
#endif

View File

@@ -0,0 +1,726 @@
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
}
.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(.headline)
Text(self.summaryDetail)
.font(.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
}
}
struct SettingsChannelsScreen: View {
let headerLeadingAction: OpenClawSidebarHeaderAction?
let gatewayAction: (() -> Void)?
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
self.headerLeadingAction = headerLeadingAction
self.gatewayAction = gatewayAction
}
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
self.header
SettingsChannelsDestination(showsSummaryCard: false)
}
.padding(.top, 18)
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
}
}
.navigationTitle("Channels")
.navigationBarTitleDisplayMode(.inline)
}
private var header: some View {
HStack(alignment: .top, spacing: 12) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
VStack(alignment: .leading, spacing: 5) {
Text("Channels / Integrations")
.font(.title3.weight(.semibold))
Text("Message routing and external channel clients.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
self.gatewayPill
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private var gatewayPill: some View {
if let gatewayAction {
Button(action: gatewayAction) {
OpenClawGatewayCompactPill()
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
OpenClawGatewayCompactPill()
}
}
}
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(.subheadline.weight(.semibold))
Text(self.entry.detailText)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let lastError = self.entry.lastError {
Text(lastError)
.font(.caption2.weight(.medium))
.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")
.foregroundStyle(account.color)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 2) {
Text(account.displayName)
.font(.caption.weight(.semibold))
Text(account.detailText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Menu {
if account.running {
Button("Stop") {
self.stop(account.id)
}
} else {
Button("Start") {
self.start(account.id)
}
.disabled(!account.configured || !account.enabled)
}
if account.linked {
Button("Logout", role: .destructive) {
self.logout(account.id)
}
}
} label: {
Image(systemName: self.actionMenuIcon(account))
}
.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(.subheadline.weight(.semibold))
.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

@@ -58,9 +58,38 @@ struct SettingsProTab: View {
@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?
init(
initialRoute: SettingsRoute? = nil,
directRoute: SettingsRoute? = nil,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil)
{
self.initialRoute = initialRoute
self.directRoute = directRoute
self.headerLeadingAction = headerLeadingAction
}
var body: some View {
NavigationStack {
self.settingsModalPresentation(
self.settingsLifecycle(
self.settingsContent))
}
@ViewBuilder
private var settingsContent: some View {
if let directRoute {
self.destination(for: directRoute)
} else {
self.settingsNavigationStack
}
}
private var settingsNavigationStack: some View {
NavigationStack(path: self.$navigationPath) {
ZStack {
OpenClawProBackground()
ScrollView {
@@ -78,11 +107,17 @@ struct SettingsProTab: View {
.navigationDestination(for: SettingsRoute.self) { route in
self.destination(for: route)
}
}
}
private func settingsLifecycle(_ content: some View) -> some View {
content
.task {
self.previousLocationModeRaw = self.locationModeRaw
self.syncSettingsState()
self.refreshNotificationSettings()
self.applyPendingGatewaySetupLinkIfNeeded()
self.applyInitialRouteIfNeeded()
}
.onChange(of: self.scenePhase) { _, phase in
if phase == .active {
@@ -119,66 +154,76 @@ struct SettingsProTab: View {
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.applyPendingGatewaySetupLinkIfNeeded()
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
})
}
private func settingsModalPresentation(_ content: some View) -> some View {
content
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
})
}
}
}
.sheet(isPresented: self.$showTalkIssueDetails) {
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
TalkRuntimeIssueDetailsSheet(issue: issue)
.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)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { self.showQRScanner = false }
.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)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { self.showQRScanner = false }
}
}
}
}
}
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
self.resetOnboarding()
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
self.resetOnboarding()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
}
.alert(
"QR Scanner Unavailable",
isPresented: Binding(
get: { self.scannerError != nil },
set: { if !$0 { self.scannerError = nil } }))
{
Button("OK", role: .cancel) {}
} message: {
Text(self.scannerError ?? "")
}
.alert(
"QR Scanner Unavailable",
isPresented: Binding(
get: { self.scannerError != nil },
set: { if !$0 { self.scannerError = nil } }))
{
Button("OK", role: .cancel) {}
} message: {
Text(self.scannerError ?? "")
}
}
private func applyInitialRouteIfNeeded() {
guard self.directRoute == nil else { return }
guard let initialRoute else { return }
guard self.navigationPath != [initialRoute] else { return }
self.navigationPath = [initialRoute]
}
}

View File

@@ -495,6 +495,7 @@ extension SettingsProTab {
case .gateway: "Gateway"
case .approvals: "Approvals"
case .permissions: "Permissions"
case .channels: "Channels"
case .voice: "Voice & Talk"
case .diagnostics: "Diagnostics"
case .privacy: "Privacy"
@@ -503,6 +504,20 @@ extension SettingsProTab {
}
}
func subtitle(for route: SettingsRoute) -> String {
switch route {
case .gateway: "Pairing, diagnostics, and Tailscale checks."
case .approvals: "Review pending agent actions."
case .permissions: "Control device capabilities."
case .channels: "Message routing and external clients."
case .voice: "Talk mode and wake phrase settings."
case .diagnostics: "Run local health checks."
case .privacy: "Data and device privacy controls."
case .notifications: "Alert permissions and delivery."
case .about: "Version and support details."
}
}
var manualPortBinding: Binding<String> {
Binding(
get: { self.manualGatewayPortText },

View File

@@ -3,10 +3,20 @@ import SwiftUI
extension SettingsProTab {
var settingsHeader: some View {
Text("Settings")
.font(.system(size: 28, weight: .bold))
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 6)
OpenClawAdaptiveHeaderRow(
title: "Settings",
subtitle: "Gateway, permissions, voice, and device controls.",
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 6)
}
var appearanceSection: some View {
@@ -131,6 +141,11 @@ extension SettingsProTab {
title: "Permissions",
detail: self.permissionsDetail,
route: .permissions)
self.settingsListRow(
icon: "point.3.connected.trianglepath.dotted",
title: "Channels / Integrations",
detail: "Message routing and external channel clients.",
route: .channels)
self.settingsListRow(
icon: "waveform",
title: "Voice & Talk",
@@ -199,6 +214,9 @@ extension SettingsProTab {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
if self.headerLeadingAction != nil {
self.routeHeader(for: route)
}
switch route {
case .gateway:
self.gatewayDestination
@@ -206,6 +224,8 @@ extension SettingsProTab {
self.approvalsDestination
case .permissions:
self.permissionsDestination
case .channels:
SettingsChannelsDestination()
case .voice:
self.voiceDestination
case .diagnostics:
@@ -224,6 +244,24 @@ extension SettingsProTab {
}
.navigationTitle(self.title(for: route))
.navigationBarTitleDisplayMode(.inline)
.toolbar(self.headerLeadingAction == nil ? .visible : .hidden, for: .navigationBar)
}
func routeHeader(for route: SettingsRoute) -> some View {
OpenClawAdaptiveHeaderRow(
title: self.title(for: route),
subtitle: self.subtitle(for: route),
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 6)
}
var gatewayDestination: some View {

View File

@@ -1,10 +1,12 @@
import Darwin
import OpenClawKit
import SwiftUI
enum SettingsRoute: Hashable {
case gateway
case approvals
case permissions
case channels
case voice
case diagnostics
case privacy
@@ -150,3 +152,176 @@ extension SettingsProTab {
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") {
GatewayProblemBanner(
problem: Self.pairingProblem,
primaryActionTitle: "Retry",
onPrimaryAction: {},
onShowDetails: {})
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(.subheadline.weight(.semibold))
.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(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(value)
.font(.caption.weight(.medium))
.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(.caption)
.foregroundStyle(.secondary)
}
}
}
private func previewButton(
_ title: String,
systemImage: String,
isBusy: Bool) -> some View
{
Button {} label: {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(isBusy)
}
private static let pairingProblem = GatewayConnectionProblem(
kind: .pairingRequired,
owner: .gateway,
title: "Pairing required",
message: "Run /pair approve in your OpenClaw chat before this iPad can connect.",
actionCommand: "/pair approve req-ipad-preview",
requestId: "req-ipad-preview",
retryable: false,
pauseReconnect: true)
}
#endif

View File

@@ -9,8 +9,17 @@ struct TalkProTab: View {
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
@State private var showPermissionPrompt = false
@State private var showTalkIssueDetails = false
let headerLeadingAction: OpenClawSidebarHeaderAction?
var openSettings: () -> Void
init(
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
openSettings: @escaping () -> Void)
{
self.headerLeadingAction = headerLeadingAction
self.openSettings = openSettings
}
private var state: TalkProState {
TalkProState(
gatewayConnected: self.gatewayConnected,
@@ -85,6 +94,9 @@ struct TalkProTab: View {
private var header: some View {
HStack(alignment: .center, spacing: 11) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
OpenClawProMark(size: 31, shadowRadius: 9)
VStack(alignment: .leading, spacing: 2) {
Text("Talk")

View File

@@ -116,6 +116,8 @@ final class NodeAppModel {
self.operatorConnected
}
private(set) var hasOperatorAdminScope: Bool = false
var gatewayServerName: String?
var gatewayRemoteAddress: String?
var connectedGatewayID: String?
@@ -297,6 +299,7 @@ final class NodeAppModel {
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled)
self.talkMode.attachGateway(self.operatorGateway)
self.refreshOperatorAdminScopeFromStore()
self.refreshLastShareEventFromRelay()
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
self.setTalkEnabled(talkEnabled)
@@ -2757,6 +2760,15 @@ extension NodeAppModel {
private func setOperatorConnected(_ connected: Bool) {
self.operatorConnected = connected
self.operatorStatusText = connected ? "Connected" : "Offline"
self.refreshOperatorAdminScopeFromStore()
}
private func refreshOperatorAdminScopeFromStore() {
let identity = DeviceIdentityStore.loadOrCreate()
self.hasOperatorAdminScope = DeviceAuthStore
.loadToken(deviceId: identity.deviceId, role: "operator")?
.scopes
.contains("operator.admin") == true
}
}
@@ -4636,6 +4648,10 @@ extension NodeAppModel {
self.gatewayConnected
}
func _test_refreshOperatorAdminScopeFromStore() {
self.refreshOperatorAdminScopeFromStore()
}
func _test_applyPendingForegroundNodeActions(
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
{

View File

@@ -8,6 +8,8 @@ struct RootTabs: View {
@Environment(VoiceWakeManager.self) private var voiceWake
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.rootTabsUserInterfaceIdiomOverride) private var userInterfaceIdiomOverride
@Environment(\.scenePhase) private var scenePhase
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
@@ -21,10 +23,14 @@ struct RootTabs: View {
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
AppAppearancePreference.system.rawValue
@State private var selectedTab: AppTab = Self.initialTab
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
@State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false
@State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil
@State private var isSidebarDrawerLayout: Bool = false
@State private var didResolveSidebarLayout: Bool = false
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@State private var presentedSheet: PresentedSheet?
@State private var showGatewayActions: Bool = false
@State private var showGatewayProblemDetails: Bool = false
@State private var showOnboarding: Bool = false
@State private var onboardingAllowSkip: Bool = true
@@ -34,14 +40,6 @@ struct RootTabs: View {
@State private var didApplyInitialChatSession: Bool = false
@State private var handledGatewaySetupRequestID: Int = 0
private enum AppTab: Hashable {
case control
case chat
case talk
case agent
case settings
}
private static var initialTab: AppTab {
let arguments = ProcessInfo.processInfo.arguments
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-tab") else {
@@ -66,6 +64,28 @@ struct RootTabs: View {
}
}
private static var initialSidebarDestination: SidebarDestination {
if let requested = requestedInitialSidebarDestination {
return requested
}
return Self.defaultSidebarDestination(for: initialTab)
}
private static var requestedInitialSidebarDestination: SidebarDestination? {
let arguments = ProcessInfo.processInfo.arguments
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-destination") else {
return nil
}
let valueIndex = arguments.index(after: flagIndex)
guard arguments.indices.contains(valueIndex) else { return nil }
let requested = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return SidebarDestination.allCases.first { $0.rawValue.lowercased() == requested }
}
private static var initialSidebarVisibility: Bool? {
requestedInitialSidebarVisibility(arguments: ProcessInfo.processInfo.arguments)
}
private static var initialChatSessionKey: String? {
let arguments = ProcessInfo.processInfo.arguments
guard let flagIndex = arguments.firstIndex(of: "--openclaw-chat-session") else {
@@ -87,45 +107,11 @@ struct RootTabs: View {
}
}
enum StartupPresentationRoute: Equatable {
case none
case onboarding
case settings
}
static func startupPresentationRoute(
gatewayConnected: Bool,
hasConnectedOnce: Bool,
onboardingComplete: Bool,
hasExistingGatewayConfig: Bool,
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
static func shouldUseSidebarTabs(
idiom: UIUserInterfaceIdiom,
horizontalSizeClass _: UserInterfaceSizeClass?) -> Bool
{
if gatewayConnected {
return .none
}
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
return .onboarding
}
if !hasExistingGatewayConfig {
return .settings
}
return .none
}
static func shouldPresentQuickSetup(
quickSetupDismissed: Bool,
showOnboarding: Bool,
hasPresentedSheet: Bool,
gatewayConnected: Bool,
hasExistingGatewayConfig: Bool,
discoveredGatewayCount: Int) -> Bool
{
guard !quickSetupDismissed else { return false }
guard !showOnboarding else { return false }
guard !hasPresentedSheet else { return false }
guard !gatewayConnected else { return false }
guard !hasExistingGatewayConfig else { return false }
return discoveredGatewayCount > 0
idiom == .pad
}
var body: some View {
@@ -136,20 +122,30 @@ struct RootTabs: View {
.tint(OpenClawBrand.accent))))
}
@ViewBuilder
private var tabContent: some View {
if self.usesSidebarTabs {
self.sidebarSplitContent
} else {
self.phoneTabContent
}
}
private var phoneTabContent: some View {
TabView(selection: self.$selectedTab) {
CommandCenterTab(
openChat: { self.selectedTab = .chat },
openSettings: { self.selectedTab = .settings })
.tabItem { Label("Command", systemImage: "target") }
RootTabsPhoneControlHub(
groups: Self.phoneControlGroups,
initialDestination: Self.requestedInitialSidebarDestination,
openRootDestination: { self.selectSidebarDestination($0) })
.tabItem { Label("Control", systemImage: "square.grid.2x2") }
.badge(self.appModel.pendingExecApprovalPrompt == nil ? 0 : 1)
.tag(AppTab.control)
ChatProTab()
ChatProTab(openSettings: { self.selectSidebarDestination(.gateway) })
.tabItem { Label("Chat", systemImage: "bubble.left.fill") }
.tag(AppTab.chat)
TalkProTab(openSettings: { self.selectedTab = .settings })
TalkProTab(openSettings: { self.selectSidebarDestination(.gateway) })
.tabItem {
Label(
"Talk",
@@ -157,16 +153,394 @@ struct RootTabs: View {
}
.tag(AppTab.talk)
AgentProTab()
.tabItem { Label("Agent", systemImage: "person.2.fill") }
.tag(AppTab.agent)
NavigationStack {
AgentProTab(
directRoute: .agents,
openSettings: { self.selectSidebarDestination(.gateway) })
}
.tabItem { Label("Agent", systemImage: "person.2.fill") }
.tag(AppTab.agent)
SettingsProTab()
SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)
.id(self.selectedSidebarDestination.settingsRoute.map { "\($0)" } ?? "settings")
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
.tag(AppTab.settings)
}
}
private var sidebarSplitContent: some View {
GeometryReader { proxy in
let isDrawerLayout = self.shouldUseSidebarDrawer(containerSize: proxy.size)
let sidebarWidth = self.sidebarWidth(containerWidth: proxy.size.width, isDrawerLayout: isDrawerLayout)
Group {
if isDrawerLayout {
self.sidebarDrawerContent(sidebarWidth: sidebarWidth)
} else {
self.sidebarNavigationSplitContent(sidebarWidth: sidebarWidth)
}
}
.animation(.easeInOut(duration: 0.22), value: self.isSidebarVisible)
.onAppear {
self.updateSidebarLayout(containerSize: proxy.size, force: false)
}
.onChange(of: proxy.size) { _, size in
self.updateSidebarLayout(containerSize: size, force: false)
}
}
}
private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View {
HStack(spacing: 0) {
if self.isSidebarVisible {
self.sidebarColumn
.frame(width: sidebarWidth, alignment: .topLeading)
.frame(maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
self.sidebarVerticalSeparator
}
.transition(.move(edge: .leading).combined(with: .opacity))
}
self.sidebarDetailNavigationShell
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.background(OpenClawProBackground())
}
private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View {
ZStack(alignment: .topLeading) {
self.sidebarDetailNavigationShell
.frame(maxWidth: .infinity, maxHeight: .infinity)
if self.isSidebarVisible {
Color.black.opacity(0.28)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {
self.hideSidebar()
}
.transition(.opacity)
self.sidebarColumn
.frame(width: sidebarWidth, alignment: .topLeading)
.frame(maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
self.sidebarVerticalSeparator
}
.shadow(color: .black.opacity(0.26), radius: 18, x: 8, y: 0)
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
}
private var sidebarDetailShell: some View {
self.sidebarDetail
.id(self.selectedSidebarDestination.id)
}
private var sidebarColumn: some View {
VStack(spacing: 0) {
self.sidebarIdentityHeader
self.sidebarList
self.sidebarFooter
}
.safeAreaPadding(.top, 8)
.safeAreaPadding(.bottom, 8)
.background(Color(uiColor: .systemBackground))
}
private var sidebarIdentityHeader: some View {
HStack(spacing: 10) {
OpenClawProMark(size: 30, shadowRadius: 3)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text("OpenClaw")
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
HStack(spacing: 4) {
Image(systemName: "circle.fill")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(self.sidebarGatewayStatusColor)
Text(self.sidebarGatewayStatusTitle)
.lineLimit(1)
}
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
if self.isSidebarDrawerLayout {
self.sidebarHideButton
}
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(Color(uiColor: .systemBackground))
.overlay(alignment: .bottom) {
self.sidebarHorizontalSeparator
}
.accessibilityElement(children: .combine)
.accessibilityLabel("OpenClaw \(self.sidebarGatewayStatusTitle)")
}
private var sidebarGatewayStatusTitle: String {
switch self.gatewayStatus {
case .connected:
"Online"
case .connecting:
"Connecting"
case .error:
"Needs attention"
case .disconnected:
"Offline"
}
}
private var sidebarList: some View {
List {
ForEach(Self.sidebarGroups) { group in
Section(group.title.capitalized) {
ForEach(group.destinations) { destination in
self.sidebarDestinationButton(destination)
}
}
.listSectionSeparator(.hidden, edges: .all)
}
}
.listStyle(.sidebar)
.tint(OpenClawBrand.accent)
.scrollContentBackground(.hidden)
.background(Color(uiColor: .systemBackground))
}
private var sidebarFooter: some View {
VStack(spacing: 0) {
self.sidebarHorizontalSeparator
HStack(spacing: 10) {
Text("VERSION")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text("v\(DeviceInfoHelper.openClawVersionString())")
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.72)
ProStatusDot(color: self.sidebarGatewayStatusColor)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
}
private var sidebarHorizontalSeparator: some View {
Rectangle()
.fill(Color(uiColor: .separator))
.frame(height: 1 / UIScreen.main.scale)
}
private var sidebarVerticalSeparator: some View {
Rectangle()
.fill(Color(uiColor: .separator))
.frame(width: 1 / UIScreen.main.scale)
}
private var sidebarGatewayStatusColor: Color {
switch self.gatewayStatus {
case .connected:
OpenClawBrand.ok
case .connecting:
OpenClawBrand.accent
case .error:
OpenClawBrand.warn
case .disconnected:
.secondary
}
}
private func sidebarDestinationButton(
_ destination: SidebarDestination,
title: String? = nil) -> some View
{
Button {
self.selectSidebarDestination(destination)
} label: {
Label(title ?? destination.sidebarTitle, systemImage: destination.systemImage)
.lineLimit(1)
.minimumScaleFactor(0.82)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.foregroundStyle(destination == self.selectedSidebarDestination ? OpenClawBrand.accent : .primary)
.listRowBackground(
destination == self.selectedSidebarDestination
? OpenClawBrand.accent.opacity(0.12)
: Color.clear)
.listRowSeparator(.hidden, edges: .all)
}
@ViewBuilder
private var sidebarDetail: some View {
switch self.selectedSidebarDestination {
case .chat:
ChatProTab(
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Chat",
headerSubtitle: "Agent conversation",
showsAgentBadge: false,
openSettings: { self.selectSidebarDestination(.gateway) })
case .talk:
TalkProTab(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openSettings: { self.selectSidebarDestination(.gateway) })
case .overview:
CommandCenterTab(
headerTitle: "Overview",
headerLeadingAction: self.sidebarHeaderLeadingAction,
showsHeaderMark: false,
openChat: { self.selectSidebarDestination(.chat) },
openSettings: { self.selectSidebarDestination(.gateway) })
case .activity:
IPadActivityScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openChat: { self.selectSidebarDestination(.chat) },
openSettings: { self.selectSidebarDestination(.gateway) })
case .workboard:
IPadWorkboardScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openChat: { self.selectSidebarDestination(.chat) },
openSettings: { self.selectSidebarDestination(.gateway) })
case .skillWorkshop:
IPadSkillWorkshopScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openSettings: { self.selectSidebarDestination(.gateway) })
case .agents:
AgentProTab(
directRoute: .agents,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Agents",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .instances:
AgentProTab(
directRoute: .instances,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Instances",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .sessions:
CommandSessionsScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openChat: { self.selectSidebarDestination(.chat) })
case .dreaming:
AgentProTab(
directRoute: .dreaming,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Dreaming",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .usage:
AgentProTab(
directRoute: .usage,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Usage",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .cron:
AgentProTab(
directRoute: .cron,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Cron Jobs",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .docs:
OpenClawDocsScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
gatewayAction: { self.selectSidebarDestination(.gateway) })
case .settings:
SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)
case .gateway:
SettingsProTab(
directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway,
headerLeadingAction: self.sidebarHeaderLeadingAction)
}
}
private var sidebarDetailNavigationShell: some View {
NavigationStack {
self.sidebarDetailShell
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.clipped()
}
private var usesSidebarTabs: Bool {
Self.shouldUseSidebarTabs(
idiom: self.userInterfaceIdiom,
horizontalSizeClass: self.horizontalSizeClass)
}
private var userInterfaceIdiom: UIUserInterfaceIdiom {
if let userInterfaceIdiomOverride {
return userInterfaceIdiomOverride
}
return UIDevice.current.userInterfaceIdiom
}
private var shouldCollapseSidebarAfterSelection: Bool {
Self.shouldCollapseSidebarAfterSelection(
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
}
private var sidebarHeaderLeadingAction: OpenClawSidebarHeaderAction? {
guard Self.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: self.isSidebarVisible,
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
else {
return nil
}
if self.isSidebarVisible {
return OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Hide Sidebar",
accessibilityIdentifier: Self.sidebarHideButtonAccessibilityIdentifier,
action: { self.hideSidebar() })
}
return OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Show Sidebar",
accessibilityIdentifier: Self.sidebarShowButtonAccessibilityIdentifier,
action: { self.showSidebar() })
}
private var sidebarHideButton: some View {
Button {
self.hideSidebar()
} label: {
Image(systemName: self.isSidebarDrawerLayout ? "xmark" : "sidebar.left")
.font(.system(size: 15, weight: .semibold))
}
.frame(width: 44, height: 44)
.contentShape(Rectangle())
.buttonStyle(.plain)
.foregroundStyle(OpenClawBrand.accent)
.accessibilityLabel("Hide Sidebar")
.accessibilityIdentifier(Self.sidebarHideButtonAccessibilityIdentifier)
}
private func shouldUseSidebarDrawer(containerSize: CGSize) -> Bool {
Self.sidebarLayoutMode(containerSize: containerSize) == .drawer
}
private func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
Self.sidebarWidth(containerWidth: containerWidth, isDrawerLayout: isDrawerLayout)
}
private func rootOverlays(_ content: some View) -> some View {
content
.overlay(alignment: .top) {
@@ -195,6 +569,7 @@ struct RootTabs: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.overlay {
if self.appModel.cameraFlashNonce != 0 {
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
@@ -325,7 +700,7 @@ struct RootTabs: View {
self.evaluateOnboardingPresentation(force: true)
}
.onChange(of: self.appModel.openChatRequestID) { _, _ in
self.selectedTab = .chat
self.selectSidebarDestination(.chat)
}
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.maybeOpenSettingsForGatewaySetup()
@@ -334,10 +709,6 @@ struct RootTabs: View {
private func rootPresentation(_ content: some View) -> some View {
content
.gatewayActionsDialog(
isPresented: self.$showGatewayActions,
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.selectedTab = .settings })
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
@@ -504,6 +875,50 @@ struct RootTabs: View {
self.normalized(agent.name) ?? agent.id
}
private func selectSidebarDestination(_ destination: SidebarDestination) {
self.selectedSidebarDestination = destination
self.selectedTab = destination.appTab
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
withAnimation(.easeInOut(duration: 0.22)) {
self.setSidebarVisible(false)
}
}
private func showSidebar() {
self.sidebarVisibilityUserOverridden = true
withAnimation(.easeInOut(duration: 0.22)) {
self.setSidebarVisible(true)
}
}
private func hideSidebar() {
self.sidebarVisibilityUserOverridden = true
withAnimation(.easeInOut(duration: 0.22)) {
self.setSidebarVisible(false)
}
}
private func updateSidebarLayout(containerSize: CGSize, force: Bool) {
let layoutMode = Self.sidebarLayoutMode(containerSize: containerSize)
let previousLayoutMode: SidebarLayoutMode = self.isSidebarDrawerLayout ? .drawer : .split
let didResolvePreviousLayout = self.didResolveSidebarLayout
let layoutModeDidChange = layoutMode != previousLayoutMode
self.didResolveSidebarLayout = true
self.isSidebarDrawerLayout = layoutMode == .drawer
if layoutModeDidChange && didResolvePreviousLayout {
self.sidebarVisibilityUserOverridden = false
}
guard force || !self.sidebarVisibilityUserOverridden else { return }
let preferredVisibility = Self.preferredSidebarVisibility(layoutMode: layoutMode)
guard self.isSidebarVisible != preferredVisibility else { return }
self.setSidebarVisible(preferredVisibility)
}
private func setSidebarVisible(_ isVisible: Bool) {
self.isSidebarVisible = isVisible
}
private func homeCanvasBadge(for agent: AgentSummary) -> String {
if let identity = agent.identity,
let emoji = identity["emoji"]?.value as? String,
@@ -538,7 +953,7 @@ struct RootTabs: View {
} else if problem.retryable {
Task { await self.gatewayController.connectLastKnown() }
} else {
self.selectedTab = .settings
self.selectSidebarDestination(.gateway)
}
}
@@ -565,7 +980,7 @@ struct RootTabs: View {
self.showOnboarding = true
case .settings:
self.didAutoOpenSettings = true
self.selectedTab = .settings
self.selectSidebarDestination(.gateway)
}
}
@@ -591,7 +1006,7 @@ struct RootTabs: View {
shouldPresentOnLaunch: false)
guard route == .settings else { return }
self.didAutoOpenSettings = true
self.selectedTab = .settings
self.selectSidebarDestination(.gateway)
}
private func maybeOpenSettingsForGatewaySetup() {
@@ -601,7 +1016,7 @@ struct RootTabs: View {
self.showOnboarding = false
self.presentedSheet = nil
self.didAutoOpenSettings = true
self.selectedTab = .settings
self.selectSidebarDestination(.gateway)
}
private func applyInitialChatSessionIfNeeded() {
@@ -681,3 +1096,120 @@ private struct RootCameraFlashOverlay: View {
}
}
}
extension EnvironmentValues {
@Entry var rootTabsUserInterfaceIdiomOverride: UIUserInterfaceIdiom?
}
#if DEBUG
#Preview(
"Shell iPhone portrait",
traits: .fixedLayout(width: 393, height: 852),
.portrait)
{
RootTabsPreviewHost(idiom: .phone)
}
#Preview(
"Shell iPhone connected",
traits: .fixedLayout(width: 393, height: 852),
.portrait)
{
RootTabsPreviewHost(idiom: .phone, gatewayState: .connected)
}
#Preview(
"Shell iPhone gateway error",
traits: .fixedLayout(width: 393, height: 852),
.portrait)
{
RootTabsPreviewHost(idiom: .phone, gatewayState: .error)
}
#Preview(
"Shell iPhone landscape",
traits: .fixedLayout(width: 852, height: 393),
.landscapeLeft)
{
RootTabsPreviewHost(idiom: .phone)
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
}
#Preview(
"Shell iPad portrait drawer",
traits: .fixedLayout(width: 1024, height: 1366),
.portrait)
{
RootTabsPreviewHost(idiom: .pad)
}
#Preview(
"Shell iPad landscape split",
traits: .fixedLayout(width: 1366, height: 1024),
.landscapeLeft)
{
RootTabsPreviewHost(idiom: .pad, gatewayState: .connected)
}
#Preview(
"Shell iPad connecting",
traits: .fixedLayout(width: 1366, height: 1024),
.landscapeLeft)
{
RootTabsPreviewHost(idiom: .pad, gatewayState: .connecting)
}
#Preview(
"Shell iPad gateway error",
traits: .fixedLayout(width: 1366, height: 1024),
.landscapeLeft)
{
RootTabsPreviewHost(idiom: .pad, gatewayState: .error)
}
private struct RootTabsPreviewHost: View {
@State private var appModel: NodeAppModel
@State private var gatewayController: GatewayConnectionController
private let idiom: UIUserInterfaceIdiom
init(idiom: UIUserInterfaceIdiom, gatewayState: RootTabsPreviewGatewayState = .offline) {
let appModel = NodeAppModel()
gatewayState.apply(to: appModel)
self.idiom = idiom
_appModel = State(initialValue: appModel)
_gatewayController = State(
initialValue: GatewayConnectionController(appModel: appModel, startDiscovery: false))
}
var body: some View {
RootTabs()
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.gatewayController)
.environment(\.rootTabsUserInterfaceIdiomOverride, self.idiom)
}
}
private enum RootTabsPreviewGatewayState {
case offline
case connecting
case connected
case error
@MainActor
func apply(to appModel: NodeAppModel) {
switch self {
case .offline:
break
case .connecting:
appModel.gatewayStatusText = "Connecting..."
case .connected:
appModel.enterAppleReviewDemoMode()
case .error:
appModel.gatewayStatusText = "Gateway error: connection refused"
}
}
}
#endif

View File

@@ -0,0 +1,308 @@
import CoreGraphics
import Foundation
import SwiftUI
extension RootTabs {
private static var sidebarPersistentWidthThreshold: CGFloat {
980
}
static let sidebarSplitMinimumWidth: CGFloat = 292
static let sidebarSplitIdealWidth: CGFloat = 316
static let sidebarSplitMaximumWidth: CGFloat = 340
static let sidebarDrawerMaximumWidth: CGFloat = 340
static let sidebarShowButtonAccessibilityIdentifier = "RootTabs.Sidebar.Show"
static let sidebarHideButtonAccessibilityIdentifier = "RootTabs.Sidebar.Hide"
enum AppTab: Hashable {
case control
case chat
case talk
case agent
case settings
}
enum SidebarDestination: String, CaseIterable, Hashable, Identifiable {
case chat
case talk
case overview
case activity
case agents
case workboard
case skillWorkshop
case instances
case sessions
case dreaming
case usage
case cron
case docs
case settings
case gateway
var id: String {
rawValue
}
var title: String {
switch self {
case .chat: "Chat"
case .talk: "Talk"
case .overview: "Overview"
case .activity: "Activity"
case .agents: "Agents"
case .workboard: "Workboard"
case .skillWorkshop: "Skill Workshop"
case .instances: "Instances"
case .sessions: "Sessions"
case .dreaming: "Dreaming"
case .usage: "Usage"
case .cron: "Cron Jobs"
case .docs: "Docs"
case .settings: "Settings"
case .gateway: "Settings / Gateway"
}
}
var sidebarTitle: String {
switch self {
case .gateway: "Connection"
default: self.title
}
}
var subtitle: String {
switch self {
case .chat: "Agent chat and recent work."
case .talk: "Realtime voice and fallback controls."
case .overview: "Status, entry points, health."
case .activity: "Gateway, session, and device activity."
case .agents: "Agent roster and readiness."
case .workboard: "Agent work queue and session handoff."
case .skillWorkshop: "Review and apply proposed skills."
case .instances: "Latest presence from OpenClaw nodes."
case .sessions: "Active sessions and defaults."
case .dreaming: "Memory signals and background synthesis."
case .usage: "API usage and costs."
case .cron: "Wakeups and recurring runs."
case .docs: "Reference docs and setup guides."
case .settings: "Connection, permissions, channels, and app options."
case .gateway: "Pairing, diagnostics, permissions, and device controls."
}
}
var systemImage: String {
switch self {
case .chat: "bubble.left"
case .talk: "waveform.circle"
case .overview: "chart.bar"
case .activity: "waveform.path.ecg"
case .agents: "person.2"
case .workboard: "folder"
case .skillWorkshop: "hammer"
case .instances: "dot.radiowaves.left.and.right"
case .sessions: "doc.text"
case .dreaming: "moon.stars"
case .usage: "chart.bar.xaxis"
case .cron: "timer"
case .docs: "book"
case .settings: "gearshape"
case .gateway: "gearshape"
}
}
var appTab: AppTab {
switch self {
case .chat:
.chat
case .talk:
.talk
case .agents:
.agent
case .settings, .gateway:
.settings
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
.usage,
.cron, .docs:
.control
}
}
var settingsRoute: SettingsRoute? {
switch self {
case .gateway:
.gateway
case .chat, .talk, .overview, .activity, .agents, .workboard, .skillWorkshop, .instances, .sessions,
.dreaming,
.usage, .cron, .settings, .docs:
nil
}
}
}
enum SidebarLayoutMode: Equatable {
case drawer
case split
}
static func sidebarLayoutMode(containerSize: CGSize) -> SidebarLayoutMode {
containerSize.width < self.sidebarPersistentWidthThreshold || containerSize.height > containerSize.width
? .drawer
: .split
}
static func preferredSidebarVisibility(layoutMode: SidebarLayoutMode) -> Bool {
layoutMode == .split
}
static func shouldCollapseSidebarAfterSelection(layoutMode: SidebarLayoutMode) -> Bool {
layoutMode == .drawer
}
static func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
if isDrawerLayout {
return min(self.sidebarDrawerMaximumWidth, max(280, containerWidth * 0.86))
}
return min(self.sidebarSplitMaximumWidth, max(self.sidebarSplitIdealWidth, containerWidth * 0.25))
}
static func shouldShowSidebarRevealControl(isSidebarVisible: Bool) -> Bool {
!isSidebarVisible
}
static func shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: Bool,
layoutMode: SidebarLayoutMode) -> Bool
{
switch layoutMode {
case .split:
true
case .drawer:
self.shouldShowSidebarRevealControl(isSidebarVisible: isSidebarVisible)
}
}
static func requestedInitialSidebarVisibility(arguments: [String]) -> Bool? {
guard let flagIndex = arguments.firstIndex(of: "--openclaw-sidebar-visibility") else {
return nil
}
let valueIndex = arguments.index(after: flagIndex)
guard arguments.indices.contains(valueIndex) else { return nil }
switch arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "visible", "show", "shown", "open", "true", "1":
return true
case "hidden", "hide", "closed", "false", "0":
return false
default:
return nil
}
}
static func shouldOpenRootTabFromPhoneHub(_ destination: SidebarDestination) -> Bool {
switch destination {
case .chat, .talk, .agents, .gateway, .settings:
true
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
.usage,
.cron, .docs:
false
}
}
static func defaultSidebarDestination(for tab: AppTab) -> SidebarDestination {
switch tab {
case .control:
.overview
case .chat:
.chat
case .talk:
.talk
case .agent:
.agents
case .settings:
.settings
}
}
enum StartupPresentationRoute: Equatable {
case none
case onboarding
case settings
}
static func startupPresentationRoute(
gatewayConnected: Bool,
hasConnectedOnce: Bool,
onboardingComplete: Bool,
hasExistingGatewayConfig: Bool,
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
{
if gatewayConnected {
return .none
}
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
return .onboarding
}
if !hasExistingGatewayConfig {
return .settings
}
return .none
}
static func shouldPresentQuickSetup(
quickSetupDismissed: Bool,
showOnboarding: Bool,
hasPresentedSheet: Bool,
gatewayConnected: Bool,
hasExistingGatewayConfig: Bool,
discoveredGatewayCount: Int) -> Bool
{
guard !quickSetupDismissed else { return false }
guard !showOnboarding else { return false }
guard !hasPresentedSheet else { return false }
guard !gatewayConnected else { return false }
guard !hasExistingGatewayConfig else { return false }
return discoveredGatewayCount > 0
}
struct SidebarGroup: Identifiable {
let title: String
let destinations: [SidebarDestination]
var id: String {
self.title
}
}
static let sidebarGroups: [SidebarGroup] = [
SidebarGroup(title: "CHAT", destinations: [.chat, .talk]),
SidebarGroup(
title: "CONTROL",
destinations: [
.overview,
.activity,
.agents,
.workboard,
.skillWorkshop,
.instances,
.sessions,
.dreaming,
.usage,
.cron,
]),
SidebarGroup(
title: "SETTINGS",
destinations: [.settings]),
SidebarGroup(title: "REFERENCE", destinations: [.docs]),
]
static var phoneControlGroups: [SidebarGroup] {
self.sidebarGroups
.map { group in
SidebarGroup(
title: group.title,
destinations: group.destinations.filter { $0 != .agents })
}
.filter { !$0.destinations.isEmpty }
}
}

View File

@@ -1,25 +0,0 @@
import SwiftUI
extension View {
func gatewayActionsDialog(
isPresented: Binding<Bool>,
onDisconnect: @escaping () -> Void,
onOpenSettings: @escaping () -> Void) -> some View
{
self.confirmationDialog(
"Gateway",
isPresented: isPresented,
titleVisibility: .visible)
{
Button("Disconnect", role: .destructive) {
onDisconnect()
}
Button("Open Settings") {
onOpenSettings()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Disconnect from the gateway?")
}
}
}

View File

@@ -31,6 +31,15 @@ Sources/Design/AgentProTab+Usage.swift
Sources/Design/AgentProTab+DetailComponents.swift
Sources/Design/AgentProTab+GatewayData.swift
Sources/Design/AgentProModels.swift
Sources/Design/IPadActivityScreen.swift
Sources/Design/IPadSidebarFeaturePreviews.swift
Sources/Design/IPadSidebarFeatureScreens.swift
Sources/Design/IPadSkillWorkshopScreen.swift
Sources/Design/IPadSidebarScreenChrome.swift
Sources/Design/IPadWorkboardScreen.swift
Sources/Design/OpenClawDocsScreen.swift
Sources/Design/RootTabsPhoneControlHub.swift
Sources/Design/SettingsChannelsDestination.swift
Sources/EventKit/EventKitAuthorization.swift
Sources/Gateway/DeepLinkAgentPromptAlert.swift
Sources/Gateway/ExecApprovalPromptDialog.swift
@@ -73,6 +82,7 @@ Sources/Push/PushRelayClient.swift
Sources/Push/PushRelayKeychainStore.swift
Sources/Reminders/RemindersService.swift
Sources/RootTabs.swift
Sources/RootTabsNavigation.swift
Sources/RootView.swift
Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift
@@ -86,7 +96,6 @@ Sources/SessionKey.swift
Sources/Settings/PrivacyAccessSectionView.swift
Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift
Sources/Status/GatewayActionsDialog.swift
Sources/Status/GatewayStatusBuilder.swift
Sources/Status/VoiceWakeToast.swift
Sources/Voice/TalkGatewayPermissionState.swift

View File

@@ -0,0 +1,27 @@
import SwiftUI
import Testing
@testable import OpenClaw
@MainActor
@Suite struct CommandCenterTabLayoutTests {
@Test func splitLayoutDisabledForCompactWidth() {
#expect(
!CommandCenterTab.usesSplitSectionsLayout(
horizontalSizeClass: .compact,
containerWidth: 1_200))
}
@Test func splitLayoutDisabledBelowWidthThreshold() {
#expect(
!CommandCenterTab.usesSplitSectionsLayout(
horizontalSizeClass: .regular,
containerWidth: 900))
}
@Test func splitLayoutEnabledForRegularWideLayout() {
#expect(
CommandCenterTab.usesSplitSectionsLayout(
horizontalSizeClass: .regular,
containerWidth: 1_024))
}
}

View File

@@ -33,4 +33,12 @@ import Testing
#expect(state == .connecting)
}
@Test func chatGatewayPillLabelsMatchDisplayState() {
#expect(ChatProTab.gatewayPillTitle(state: .disconnected, isGatewayUsable: false) == "Offline")
#expect(ChatProTab.gatewayPillTitle(state: .connecting, isGatewayUsable: false) == "Connecting")
#expect(ChatProTab.gatewayPillTitle(state: .error, isGatewayUsable: false) == "Attention")
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: true) == "Connected")
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: false) == "Unavailable")
}
}

View File

@@ -1025,6 +1025,38 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(appModel.openChatRequestID == 1)
}
@Test @MainActor func operatorAdminScopeCacheRefreshesFromStoredToken() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let appModel = NodeAppModel()
let identity = DeviceIdentityStore.loadOrCreate()
#expect(appModel.hasOperatorAdminScope == false)
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "operator",
token: "operator-token",
scopes: ["operator.read", "operator.admin"])
appModel._test_refreshOperatorAdminScopeFromStore()
#expect(appModel.hasOperatorAdminScope == true)
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: "operator")
appModel._test_refreshOperatorAdminScopeFromStore()
#expect(appModel.hasOperatorAdminScope == false)
}
@Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async {
let appModel = NodeAppModel()
await #expect(throws: Error.self) {

View File

@@ -1,4 +1,6 @@
import SwiftUI
import Testing
import UIKit
@testable import OpenClaw
@MainActor
@@ -38,4 +40,432 @@ import Testing
#expect(!shouldPresent)
}
@Test func sidebarTabsEnabledForIPadRegularWidth() {
#expect(
RootTabs.shouldUseSidebarTabs(
idiom: .pad,
horizontalSizeClass: .regular))
}
@Test func sidebarTabsEnabledForIPadCompactWidth() {
#expect(
RootTabs.shouldUseSidebarTabs(
idiom: .pad,
horizontalSizeClass: .compact))
}
@Test func sidebarTabsDisabledForIPhone() {
#expect(
!RootTabs.shouldUseSidebarTabs(
idiom: .phone,
horizontalSizeClass: .regular))
}
@Test func sidebarGroupsMatchAdaptiveNavigationModel() {
let groups = RootTabs.sidebarGroups
let destinationIDs = RootTabs.SidebarDestination.allCases.map(\.rawValue)
#expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"])
#expect(groups[0].destinations.map(\.rawValue) == ["chat", "talk"])
#expect(groups[1].destinations == [
.overview,
.activity,
.agents,
.workboard,
.skillWorkshop,
.instances,
.sessions,
.dreaming,
.usage,
.cron,
])
#expect(groups[2].destinations == [.settings])
#expect(groups[3].destinations == [.docs])
#expect(destinationIDs == [
"chat",
"talk",
"overview",
"activity",
"agents",
"workboard",
"skillWorkshop",
"instances",
"sessions",
"dreaming",
"usage",
"cron",
"docs",
"settings",
"gateway",
])
#expect(!destinationIDs.contains("agent"))
#expect(!RootTabs.sidebarGroups.flatMap(\.destinations).contains(.gateway))
}
@Test func phoneControlGroupsAvoidDuplicatingTheAgentTab() {
let groups = RootTabs.phoneControlGroups
let destinations = groups.flatMap(\.destinations)
#expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"])
#expect(!destinations.contains(.agents))
#expect(RootTabs.sidebarGroups.flatMap(\.destinations).contains(.agents))
#expect(destinations.contains(.dreaming))
#expect(destinations.contains(.instances))
}
@Test func sidebarUsesCompactLabelsForLongRoutes() {
#expect(RootTabs.SidebarDestination.settings.title == "Settings")
#expect(RootTabs.SidebarDestination.gateway.title == "Settings / Gateway")
#expect(RootTabs.SidebarDestination.gateway.sidebarTitle == "Connection")
}
@Test func phoneHubUsesRootTabsOnlyForNativeChatAgentAndGateway() {
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.chat))
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.talk))
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.agents))
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.gateway))
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.settings))
for destination in RootTabs.SidebarDestination.allCases
where destination != .chat && destination != .talk && destination != .agents && destination != .gateway && destination != .settings
{
#expect(!RootTabs.shouldOpenRootTabFromPhoneHub(destination))
}
}
@Test func legacyInitialTabsMapToMatchingSidebarDestinations() {
#expect(RootTabs.defaultSidebarDestination(for: .control) == .overview)
#expect(RootTabs.defaultSidebarDestination(for: .chat) == .chat)
#expect(RootTabs.defaultSidebarDestination(for: .talk) == .talk)
#expect(RootTabs.defaultSidebarDestination(for: .agent) == .agents)
#expect(RootTabs.defaultSidebarDestination(for: .settings) == .settings)
}
@Test func skillWorkshopMutationsRequireAdminScope() {
#expect(IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: true))
#expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: false))
#expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: false, hasOperatorAdminScope: true))
}
@Test func skillWorkshopHeldFilterIncludesQuarantinedAndStale() {
#expect(IPadSkillWorkshopScreen.proposalStatusFilters.contains("held"))
#expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "quarantined", filter: "held"))
#expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "stale", filter: "held"))
#expect(!IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "pending", filter: "held"))
}
@Test func skillWorkshopBoardLanesMatchStatusFilter() {
#expect(
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
filter: "pending",
proposalStatuses: ["pending", "applied"]) == ["pending"])
#expect(
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
filter: "held",
proposalStatuses: ["quarantined", "stale"]) == ["quarantined", "stale"])
#expect(
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
filter: "all",
proposalStatuses: ["pending", "needs-review"]) == [
"pending",
"quarantined",
"stale",
"applied",
"rejected",
"needs-review",
])
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("quarantined") == "Quarantined")
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("pending") == "Pending")
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("needs-review") == "Needs Review")
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("manual_QA") == "Manual QA")
}
@Test func skillWorkshopSelectionStaysInsideActiveFilter() {
let proposals = [
(id: "applied-1", status: "applied"),
(id: "pending-1", status: "pending"),
(id: "held-1", status: "quarantined"),
]
#expect(
IPadSkillWorkshopScreen.nextSelectedProposalID(
current: "applied-1",
proposals: proposals,
filter: "pending") == "pending-1")
#expect(
IPadSkillWorkshopScreen.nextSelectedProposalID(
current: "held-1",
proposals: proposals,
filter: "held") == "held-1")
#expect(
IPadSkillWorkshopScreen.nextSelectedProposalID(
current: "pending-1",
visibleProposalIDs: ["held-1"]) == "held-1")
#expect(
IPadSkillWorkshopScreen.nextSelectedProposalID(
current: "pending-1",
visibleProposalIDs: []) == nil)
}
@Test func workboardBoardScopeLabelsStayCompact() {
#expect(IPadWorkboardScreen.normalizedScopeID(" planning ") == "planning")
#expect(IPadWorkboardScreen.boardScopeLabel(for: "") == "All boards")
#expect(IPadWorkboardScreen.boardScopeLabel(for: "planning") == "planning")
#expect(IPadWorkboardScreen.boardScopeOptions(
knownBoardIDs: ["default", " empty-board ", ""],
cardBoardIDs: ["planning", "default"]) == ["default", "empty-board", "planning"])
#expect(IPadWorkboardScreen
.workboardSubtitle(boardScopeLabel: "All boards", selectedStatus: "active") == "All boards / Active")
#expect(IPadWorkboardScreen
.workboardSubtitle(boardScopeLabel: "planning", selectedStatus: "running") == "planning / Running")
}
@Test func workboardCompactUnavailableCopyExplainsRealCapabilityState() {
#expect(IPadWorkboardScreen
.compactWriteUnavailableMessage(canRead: false) ==
"Connect from Settings to create, move, and dispatch cards.")
#expect(IPadWorkboardScreen.compactWriteUnavailableMessage(canRead: true) == "Read-only gateway.")
}
@Test func skillWorkshopAgentScopeNormalizesGatewayIds() {
#expect(IPadSkillWorkshopScreen.normalizedScopeID(" aiden ") == "aiden")
#expect(IPadSkillWorkshopScreen.normalizedScopeID(nil) == "")
}
@Test func channelLifecycleControlsRequireAdminScope() {
#expect(SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: true))
#expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: false))
#expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: false, hasOperatorAdminScope: true))
}
@Test func clickClackStaysInChannelsIntegrationMetadata() {
#expect(SettingsChannelsDestination.fallbackLabel("clickclack") == "ClickClack")
#expect(SettingsChannelsDestination.fallbackDetail("clickclack") == "Self-hosted chat bot routing.")
#expect(SettingsChannelsDestination.fallbackSystemImage("clickclack") == "bubble.left.and.bubble.right")
}
@Test func iPadOverviewCanSuppressStandaloneHeaderBranding() {
#expect(CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: true))
#expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: true, showsHeaderMark: true))
#expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: false))
}
@Test func chatSidebarDestinationCanUseRouteHeaderInsteadOfAgentBranding() {
let standalone = ChatProTab()
let routed = ChatProTab(
headerTitle: "Chat",
headerSubtitle: "Agent conversation",
showsAgentBadge: false,
openSettings: {})
#expect(standalone.showsAgentBadge)
#expect(standalone.headerTitle == nil)
#expect(standalone.openSettings == nil)
#expect(routed.headerTitle == "Chat")
#expect(routed.headerSubtitle == "Agent conversation")
#expect(!routed.showsAgentBadge)
#expect(routed.openSettings != nil)
#expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: true, agentDisplayName: "OpenClaw") == "OpenClaw")
#expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: false, agentDisplayName: "OpenClaw") == "Chat")
}
@Test func agentRoutesCanOpenGatewaySettingsFromHeaderPill() {
let standalone = AgentProTab()
let routed = AgentProTab(
directRoute: .instances,
headerTitle: "Instances",
openSettings: {})
#expect(standalone.headerTitle == "Agents")
#expect(standalone.directRoute == nil)
#expect(standalone.openSettings == nil)
#expect(AgentProTab(directRoute: .agents).directRoute == .agents)
#expect(routed.directRoute == .instances)
#expect(routed.headerTitle == "Instances")
#expect(routed.openSettings != nil)
}
@Test func workboardDispatchSummaryReportsStartedAndFailures() throws {
let payload = Data(
"""
{
"count": 2,
"started": [{}],
"startFailures": [{}],
"promoted": [],
"reclaimed": [],
"blocked": [],
"orchestrated": []
}
""".utf8)
let summary = try JSONDecoder().decode(IPadWorkboardDispatchSummary.self, from: payload)
#expect(summary.summaryText == "2 dispatched: 1 started, 1 failed.")
}
@Test func talkSidebarDestinationCanReceiveRevealAction() {
let action = OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Show Sidebar",
action: {})
let routed = TalkProTab(headerLeadingAction: action, openSettings: {})
#expect(routed.headerLeadingAction?.systemName == "sidebar.left")
#expect(routed.headerLeadingAction?.accessibilityLabel == "Show Sidebar")
}
@Test func iPadPortraitUsesHiddenDrawerSidebar() {
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1024, height: 1366))
#expect(mode == .drawer)
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode))
}
@Test func iPadWideLandscapeUsesVisibleSplitSidebar() {
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1366, height: 1024))
#expect(mode == .split)
#expect(RootTabs.preferredSidebarVisibility(layoutMode: mode))
}
@Test func iPadSplitSidebarWidthStaysUsable() {
let width = RootTabs.sidebarWidth(containerWidth: 1366, isDrawerLayout: false)
#expect(width >= RootTabs.sidebarSplitIdealWidth)
#expect(width <= RootTabs.sidebarSplitMaximumWidth)
}
@Test func iPadCollapsedSplitSidebarUsesHeaderRevealWithoutReservedRail() {
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: false,
layoutMode: .split))
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: true,
layoutMode: .split))
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: false,
layoutMode: .drawer))
#expect(
!RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: true,
layoutMode: .drawer))
}
@Test func initialSidebarVisibilityParsesLaunchArgument() {
#expect(
RootTabs.requestedInitialSidebarVisibility(arguments: [
"OpenClaw",
"--openclaw-sidebar-visibility",
"hidden",
]) == false)
#expect(
RootTabs.requestedInitialSidebarVisibility(arguments: [
"OpenClaw",
"--openclaw-sidebar-visibility",
"visible",
]) == true)
#expect(
RootTabs.requestedInitialSidebarVisibility(arguments: [
"OpenClaw",
"--openclaw-sidebar-visibility",
"unknown",
]) == nil)
}
@Test func sidebarControlsHaveStableAccessibilityIdentifiers() {
#expect(RootTabs.sidebarShowButtonAccessibilityIdentifier == "RootTabs.Sidebar.Show")
#expect(RootTabs.sidebarHideButtonAccessibilityIdentifier == "RootTabs.Sidebar.Hide")
}
@Test func iPadDrawerSidebarWidthStaysInsideScreen() {
let width = RootTabs.sidebarWidth(containerWidth: 744, isDrawerLayout: true)
#expect(width >= 280)
#expect(width <= RootTabs.sidebarDrawerMaximumWidth)
}
@Test func narrowLandscapeKeepsDrawerSidebar() {
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 900, height: 600))
#expect(mode == .drawer)
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode))
}
@Test func drawerSelectionCollapsesSidebarButSplitSelectionDoesNot() {
#expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer))
#expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split))
}
@Test func hiddenSidebarShowsRevealControl() {
#expect(RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: false))
}
@Test func sidebarRevealControlsHideWhenSidebarIsVisible() {
#expect(!RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: true))
}
@Test func iPadSplitPrefersIntegratedVisibleSidebar() {
#expect(RootTabs.preferredSidebarVisibility(layoutMode: .split))
#expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split))
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: .drawer))
#expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer))
}
@Test func destinationHeadersOwnHiddenSidebarRevealControl() {
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: false,
layoutMode: .drawer))
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: false,
layoutMode: .split))
#expect(
!RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: true,
layoutMode: .drawer))
#expect(
RootTabs.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: true,
layoutMode: .split))
}
@Test func workboardAndSkillWorkshopUseCompactTaskFlowOnPhoneSizes() {
#expect(
IPadWorkboardScreen.usesCompactTaskFlow(
horizontalSizeClass: .compact,
verticalSizeClass: .regular))
#expect(
IPadSkillWorkshopScreen.usesCompactTaskFlow(
horizontalSizeClass: .compact,
verticalSizeClass: .regular))
#expect(
IPadWorkboardScreen.usesCompactTaskFlow(
horizontalSizeClass: .regular,
verticalSizeClass: .compact))
#expect(
IPadSkillWorkshopScreen.usesCompactTaskFlow(
horizontalSizeClass: .regular,
verticalSizeClass: .compact))
}
@Test func workboardAndSkillWorkshopKeepRegularTaskFlowOnWideIPadSizes() {
#expect(
!IPadWorkboardScreen.usesCompactTaskFlow(
horizontalSizeClass: .regular,
verticalSizeClass: .regular))
#expect(
!IPadSkillWorkshopScreen.usesCompactTaskFlow(
horizontalSizeClass: .regular,
verticalSizeClass: .regular))
}
@Test func phoneHubLeavesRoomForFloatingTabBar() {
#expect(RootTabsPhoneControlHub.bottomScrollInset(verticalSizeClass: .regular) == 112)
#expect(RootTabsPhoneControlHub.bottomScrollInset(verticalSizeClass: .compact) == 72)
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
import Testing
@Suite struct RootTabsSidebarRegressionTests {
@Test func iPadSplitHiddenSidebarUsesHeaderRevealInsteadOfReservedRail() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
let splitContent = try Self.extract(
source,
from: "private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View",
to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View")
#expect(splitContent.contains("HStack(spacing: 0)"))
#expect(splitContent.contains("self.sidebarColumn"))
#expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)"))
#expect(splitContent.contains(".overlay(alignment: .trailing)"))
#expect(!splitContent.contains("self.syncSidebarVisibility(from: visibility)"))
#expect(!source.contains("NavigationSplitViewVisibility"))
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
#expect(!splitContent.contains("NavigationSplitView"))
#expect(!splitContent.contains("self.collapsedSidebarRail"))
#expect(!source.contains("private var collapsedSidebarRail: some View"))
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
#expect(source.contains("shouldShowSidebarRevealInDestinationHeader"))
#expect(!navigationSource.contains("static let sidebarCollapsedRailWidth"))
#expect(!navigationSource.contains("static func sidebarSplitColumnVisibility(isSidebarVisible: Bool)"))
#expect(!navigationSource
.contains("static func sidebarIsVisible(splitColumnVisibility: NavigationSplitViewVisibility)"))
}
@Test func initialSidebarVisibilitySurvivesFirstLayoutMeasurement() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let layoutUpdate = try Self.extract(
source,
from: "private func updateSidebarLayout(containerSize: CGSize, force: Bool)",
to: "private func setSidebarVisible(_ isVisible: Bool)")
#expect(source.contains("@State private var didResolveSidebarLayout: Bool = false"))
#expect(layoutUpdate.contains("let didResolvePreviousLayout = self.didResolveSidebarLayout"))
#expect(layoutUpdate.contains("self.didResolveSidebarLayout = true"))
#expect(layoutUpdate.contains("if layoutModeDidChange && didResolvePreviousLayout"))
#expect(layoutUpdate.contains("guard force || !self.sidebarVisibilityUserOverridden else { return }"))
}
private static func rootTabsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/RootTabs.swift")
}
private static func rootTabsNavigationSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/RootTabsNavigation.swift")
}
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
let startRange = try #require(source.range(of: start))
let tail = source[startRange.lowerBound...]
let endRange = try #require(tail.range(of: end))
return String(tail[..<endRange.lowerBound])
}
}

View File

@@ -0,0 +1,812 @@
import Foundation
import Testing
@Suite struct RootTabsSourceGuardTests {
@Test func hiddenSidebarRevealUsesDestinationHeaderWithoutReservedRail() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let componentSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
#expect(source.contains("sidebarHeaderLeadingAction"))
#expect(source.contains("Hide Sidebar"))
#expect(source.contains("Show Sidebar"))
#expect(source.contains("shouldShowSidebarRevealInDestinationHeader"))
#expect(source.contains("layoutMode: self.isSidebarDrawerLayout ? .drawer : .split"))
#expect(componentSource.contains("OpenClawSidebarHeaderLeadingSlot"))
#expect(componentSource.contains(".frame(width: 44, height: 44, alignment: .center)"))
#expect(source.contains(".safeAreaPadding(.top, 8)"))
#expect(source.contains("Self.sidebarShowButtonAccessibilityIdentifier"))
#expect(source.contains("Self.sidebarHideButtonAccessibilityIdentifier"))
#expect(source.contains("accessibilityLabel: \"Hide Sidebar\""))
#expect(source.contains("accessibilityLabel: \"Show Sidebar\""))
#expect(source.contains("action: { self.hideSidebar() }"))
#expect(source.contains("action: { self.showSidebar() }"))
#expect(!source.contains("private var collapsedSidebarRail: some View"))
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
#expect(source.contains("requestedInitialSidebarVisibility"))
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
#expect(!source.contains("NavigationSplitView(columnVisibility: self.$splitColumnVisibility)"))
#expect(source.contains("HStack(spacing: 0)"))
#expect(!source.contains("self.syncSidebarVisibility(from: visibility)"))
#expect(!source.contains("shouldReserveSidebarRevealInset"))
#expect(!source.contains("safeAreaInset(edge: .top"))
#expect(!source.contains("thinMaterial, in: Circle"))
#expect(!source.contains("sidebarRevealInset"))
#expect(source.contains("Color.black.opacity(0.28)"))
#expect(source.contains(".background(Color(uiColor: .systemBackground))"))
#expect(!source.contains("sidebarRevealCornerButton"))
#expect(!source.contains("shouldShowSidebarRevealOverlay"))
#expect(!source.contains("shouldShowOverviewHeaderSidebarReveal"))
}
@Test func iPadSplitUsesSlidingSidebarWhilePortraitKeepsDrawerOverlay() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let splitContent = try Self.extract(
source,
from: "private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View",
to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View")
let drawerContent = try Self.extract(
source,
from: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View",
to: "private var sidebarDetailShell: some View")
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
#expect(!source.contains("Self.sidebarSplitColumnVisibility(isSidebarVisible:"))
#expect(!source.contains("self.syncSidebarVisibility(from: visibility)"))
#expect(splitContent.contains("HStack(spacing: 0)"))
#expect(splitContent.contains("self.sidebarColumn"))
#expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)"))
#expect(splitContent.contains(".overlay(alignment: .trailing)"))
#expect(splitContent.contains("self.sidebarVerticalSeparator"))
#expect(splitContent.contains("self.sidebarDetailNavigationShell"))
#expect(!splitContent.contains("NavigationSplitView"))
#expect(!splitContent.contains("self.collapsedSidebarRail"))
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
#expect(drawerContent.contains("ZStack(alignment: .topLeading)"))
#expect(drawerContent.contains("Color.black.opacity(0.28)"))
#expect(drawerContent.contains(".transition(.move(edge: .leading).combined(with: .opacity))"))
#expect(!drawerContent.contains("NavigationSplitView"))
}
@Test func sidebarKeepsNavigationModelDestinationOnly() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
let sidebarColumn = try Self.extract(
source,
from: "private var sidebarColumn: some View",
to: "private var sidebarList: some View")
#expect(source.contains("ForEach(Self.sidebarGroups)"))
#expect(!source.contains("Section(\"Context\")"))
#expect(!source.contains("sidebarAgentMenu"))
#expect(!source.contains("sidebarDeviceMenu"))
#expect(sidebarColumn.contains("self.sidebarIdentityHeader"))
#expect(source.contains("private var sidebarIdentityHeader: some View"))
#expect(source.contains("OpenClawProMark(size: 30"))
#expect(source.contains("Text(\"OpenClaw\")"))
#expect(source.contains("private var sidebarGatewayStatusTitle: String"))
#expect(source.contains("private var sidebarGatewayStatusColor: Color"))
#expect(!sidebarColumn.contains("activeAgent"))
#expect(!source.contains("shouldShowSidebarColumnHeader"))
#expect(!source.contains("private var sidebarColumnHeader: some View"))
#expect(sidebarColumn.contains(".safeAreaPadding(.top, 8)"))
#expect(source.contains(".scrollContentBackground(.hidden)"))
#expect(source.contains(".listStyle(.sidebar)"))
#expect(source.contains("private var sidebarHorizontalSeparator: some View"))
#expect(source.contains("private var sidebarVerticalSeparator: some View"))
#expect(source.contains("1 / UIScreen.main.scale"))
#expect(!source.contains("geometry.size.height >= Self.sidebarListNonScrollingMinimumHeight"))
#expect(!source.contains("private var sidebarListContent: some View"))
#expect(source.contains(".listRowSeparator(.hidden, edges: .all)"))
#expect(source.contains(".listSectionSeparator(.hidden, edges: .all)"))
#expect(source.contains("if self.isSidebarDrawerLayout {"))
#expect(source.contains("private var sidebarFooter: some View"))
#expect(!source.contains("LabeledContent(\"Version\""))
#expect(navigationSource.contains("SidebarGroup(title: \"CHAT\", destinations: [.chat, .talk])"))
#expect(!navigationSource.contains("title: \"AGENT\""))
#expect(navigationSource.contains("case settings"))
#expect(!navigationSource.contains("case settingsChannels"))
#expect(!navigationSource.contains("case settingsApprovals"))
#expect(!navigationSource.contains("case settingsPrivacy"))
#expect(navigationSource.contains("SidebarGroup(\n title: \"SETTINGS\""))
#expect(navigationSource.contains("destinations: [.settings]"))
#expect(!navigationSource.contains("destinations: [.gateway"))
#expect(!navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.settings"))
#expect(navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.docs])"))
}
@Test func sidebarRoutesUseDestinationHeadersInsteadOfRepeatedProductBranding() throws {
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
let sidebarDetail = try Self.extract(
rootSource,
from: "private var sidebarDetail: some View",
to: "private var sidebarDetailNavigationShell: some View")
#expect(sidebarDetail.contains("headerTitle: \"Chat\""))
#expect(sidebarDetail.contains("headerTitle: \"Overview\""))
#expect(sidebarDetail.contains("headerTitle: \"Agents\""))
#expect(sidebarDetail.contains("headerTitle: \"Instances\""))
#expect(!sidebarDetail.contains("headerTitle: \"Nodes\""))
#expect(sidebarDetail.contains("directRoute: .agents"))
#expect(sidebarDetail.contains("directRoute: .instances"))
#expect(sidebarDetail.contains("directRoute: .dreaming"))
#expect(sidebarDetail.contains("directRoute: .usage"))
#expect(sidebarDetail.contains("directRoute: .cron"))
#expect(!sidebarDetail.contains("initialRoute: .nodes"))
#expect(!sidebarDetail.contains("initialRoute: .usage"))
#expect(!sidebarDetail.contains("initialRoute: .cron"))
#expect(sidebarDetail.contains("headerTitle: \"Dreaming\""))
#expect(sidebarDetail.contains("headerTitle: \"Usage\""))
#expect(sidebarDetail.contains("headerTitle: \"Cron Jobs\""))
#expect(!sidebarDetail.contains("headerTitle: \"OpenClaw\""))
#expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(agentOverviewSource.contains("title: self.headerTitle"))
#expect(!agentOverviewSource.contains("Text(\"OpenClaw\")"))
#expect(docsSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(docsSource.contains("title: \"Docs\""))
#expect(!docsSource.contains("Text(\"OpenClaw Docs\")"))
}
@Test func agentsDirectRouteKeepsSingleSidebarControl() throws {
let source = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
let destinationsSource = try String(contentsOf: Self.agentProTabDestinationsSourceURL(), encoding: .utf8)
let nodesSource = try String(contentsOf: Self.agentProNodesDestinationSourceURL(), encoding: .utf8)
let dreamingSource = try String(contentsOf: Self.agentProDreamingDestinationSourceURL(), encoding: .utf8)
let directDestination = try Self.extract(
source,
from: "private func directDestination(for route: AgentRoute) -> some View",
to: "private func applyInitialRouteIfNeeded()")
#expect(!directDestination.contains("ToolbarItem"))
#expect(directDestination.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .instances)"))
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .dreaming)"))
#expect(destinationsSource.contains("self.directHeader(\n for: .usage"))
#expect(destinationsSource.contains("self.directHeader(\n for: .cron"))
#expect(destinationsSource.contains("self.directRoute == route ? self.headerLeadingAction : nil"))
#expect(nodesSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
#expect(dreamingSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
}
@Test func routedHeadersUseSharedAdaptiveLayout() throws {
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
let featureChromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
#expect(componentsSource.contains("struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View"))
#expect(componentsSource.contains("ViewThatFits(in: .horizontal)"))
#expect(componentsSource.contains("private var stackedLayout: some View"))
#expect(componentsSource.contains(".layoutPriority(1)"))
#expect(componentsSource.contains(".fixedSize(horizontal: true, vertical: false)"))
#expect(featureChromeSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(docsSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(overviewSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(chatSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow("))
#expect(settingsSource.contains("OpenClawAdaptiveHeaderRow("))
}
@Test func phoneHubKeepsDocsAsDestinationOnly() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains("case .docs:"))
#expect(source.contains("OpenClawDocsScreen("))
#expect(source.contains("headerLeadingAction: self.phoneDetailBackAction"))
#expect(source.contains("gatewayAction: { self.openRootDestination(.gateway) }"))
#expect(!source.contains("Label(\"Docs\", systemImage: \"book\")"))
#expect(!source.contains("https://docs.openclaw.ai"))
}
@Test func rootShellPreviewMatrixCoversPhoneAndIPadStates() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\n \"Shell iPhone portrait\""))
#expect(source.contains("#Preview(\n \"Shell iPhone landscape\""))
#expect(source.contains("#Preview(\n \"Shell iPhone connected\""))
#expect(source.contains("#Preview(\n \"Shell iPhone gateway error\""))
#expect(source.contains("#Preview(\n \"Shell iPad portrait drawer\""))
#expect(source.contains("#Preview(\n \"Shell iPad landscape split\""))
#expect(source.contains("#Preview(\n \"Shell iPad connecting\""))
#expect(source.contains("#Preview(\n \"Shell iPad gateway error\""))
}
@Test func sharedChatPreviewMatrixCoversConnectionStates() throws {
let source = try String(contentsOf: Self.sharedChatPreviewSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Chat connected\")"))
#expect(source.contains("#Preview(\"Chat empty\")"))
#expect(source.contains("#Preview(\"Chat loading\")"))
#expect(source.contains("#Preview(\"Chat gateway error\")"))
#expect(source.contains("enum Scenario"))
#expect(source.contains("case connected"))
#expect(source.contains("case empty"))
#expect(source.contains("case loading"))
#expect(source.contains("case error"))
#expect(source.contains("Gateway not connected. Check Tailscale and retry."))
}
@Test func phoneHubKeepsContentAboveFloatingTabBar() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains(".safeAreaPadding(.bottom, self.bottomScrollInset)"))
#expect(!source.contains(".padding(.bottom, self.bottomScrollInset)"))
#expect(!source.contains("bottomViewportInset"))
#expect(!source.contains("bottomTabBarClearance"))
}
@Test func phoneHubHeaderStaysTaskFirst() throws {
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
#expect(source.contains("private var gatewayActionRow: some View"))
#expect(source.contains("self.openRootDestination(.gateway)"))
#expect(source.contains("private var phoneDetailBackAction: OpenClawSidebarHeaderAction"))
#expect(source.contains("accessibilityLabel: \"Back to Control\""))
#expect(source.contains("accessibilityIdentifier: \"OpenClawPhoneDetailBackButton\""))
#expect(source.contains(".navigationBarBackButtonHidden(true)"))
#expect(source.contains(".toolbar(.hidden, for: .navigationBar)"))
#expect(source.matches(of: /headerLeadingAction: self\.phoneDetailBackAction/).count == 10)
#expect(!source.contains("directRoute: .agents"))
#expect(!source.contains("ToolbarItem(placement: .topBarTrailing)"))
#expect(!source.contains("Image(systemName: \"gearshape\")"))
#expect(!source.contains("self.metric(label:"))
#expect(!source.contains("private func metric(label:"))
}
@Test func workboardUsesRealGatewayMethods() throws {
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
#expect(source.contains("workboard.cards.list"))
#expect(source.contains("workboard.cards.create"))
#expect(source.contains("workboard.cards.move"))
#expect(source.contains("workboard.cards.archive"))
#expect(source.contains("workboard.cards.dispatch"))
#expect(source.contains(".padding(.bottom, 12)"))
#expect(!source.contains("Workboard gateway contract unavailable"))
#expect(!source.contains("supportsGatewayContract"))
#expect(!source.contains("Compact mobile queue control"))
#expect(!source.contains("Multi-column queue control"))
}
@Test func workboardCreateActionSurfacesUnavailableReasons() throws {
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
let createFunction = try Self.extract(
source,
from: "private func createCard() async -> Bool",
to: "private func move(_ card: IPadWorkboardCard, to status: String) async")
#expect(source.contains("private var createUnavailableMessage: String?"))
#expect(source.contains("Enter a title to create a card."))
#expect(source.contains("Card creation is already in progress."))
#expect(source.contains("private func newCardButton(expands: Bool) -> some View"))
#expect(source.contains("private func beginCreateCard()"))
#expect(source.contains("self.newCardButton(expands: false)"))
#expect(source.contains("self.newCardButton(expands: true)"))
#expect(source.contains("Label(\"New Card\", systemImage: \"plus\")"))
#expect(source.contains(".accessibilityHint(\"Opens card title and notes entry\")"))
#expect(source.contains(".accessibilityHint(self.createUnavailableMessage ?? \"Creates a workboard card\")"))
#expect(source.contains("if await self.createCard()"))
#expect(source.contains(".disabled(self.isCreatingCard)"))
#expect(!source.contains("Button(\"Create\")"))
#expect(!source.contains("TextField(\"New card\""))
#expect(!source.contains(".disabled(!self.canWrite || self.draftTitle"))
#expect(createFunction.contains("self.errorText = createUnavailableMessage"))
#expect(createFunction.contains("return false"))
#expect(createFunction.contains("return true"))
}
@Test func taskScopeControlsSendRealGatewayParams() throws {
let source = try Self.iPadTaskFeatureScreensSource()
#expect(source.contains("private var boardScopeMenu: some View"))
#expect(source.contains("method: \"workboard.boards.list\""))
#expect(source.contains("IPadWorkboardListParams(boardId: selectedBoardParam)"))
#expect(source.contains("boardId: selectedBoardParam"))
#expect(source
.matches(
of: /method: "workboard\.cards\.dispatch"[\s\S]*?IPadWorkboardListParams\(boardId: selectedBoardParam\)/)
.count == 1)
#expect(source.contains("private var agentScopeMenu: some View"))
#expect(source.contains("IPadSkillProposalListParams(agentId: selectedAgentParam)"))
#expect(source.contains("agentId: selectedAgentParam"))
#expect(!source
.contains(
"params: EmptyParams(),\n timeoutSeconds: 20)\n let response = try JSONDecoder().decode(IPadSkillProposalManifest.self"))
}
@Test func compactTaskRowsKeepPhoneNativeActions() throws {
let source = try Self.iPadTaskFeatureScreensSource()
let compactControls = try Self.extract(
source,
from: "private var compactQueueControls: some View",
to: "private var compactRefreshButton: some View")
#expect(source.contains("struct IPadWorkboardQueueRow"))
#expect(source.contains("private var actionMenuItems: some View"))
#expect(source.components(separatedBy: ".contextMenu {").count - 1 >= 2)
#expect(source.components(separatedBy: ".swipeActions(edge: .leading").count - 1 >= 2)
#expect(source.components(separatedBy: ".swipeActions(edge: .trailing").count - 1 >= 2)
#expect(source.contains("@State private var presentedProposalRoute: IPadSkillProposalSheetRoute?"))
#expect(source.contains(".sheet(item: self.$presentedProposalRoute)"))
#expect(source.contains("private func selectProposal("))
#expect(!source.contains("proposalSheetPresented"))
#expect(source.contains("self.presentedSheet = .card(card)"))
#expect(!source.contains("Label(\"Gateway\", systemImage: \"network\")"))
#expect(!source.contains("Button(\"Gateway\")"))
#expect(!source.contains("actionTitle: self.canRead ? nil : \"Gateway\""))
#expect(!source.contains("Workboard offline"))
#expect(!source.contains("Workshop offline"))
#expect(!source.contains("Connect gateway to"))
#expect(source.contains("private var compactRefreshButton: some View"))
#expect(source.contains("private var compactBoardScopeMenu: some View"))
#expect(source.contains("Color(uiColor: .secondarySystemGroupedBackground)"))
#expect(source.contains(".allowsHitTesting(false)"))
#expect(compactControls.contains("self.compactRefreshButton"))
#expect(compactControls.contains("self.compactBoardScopeMenu"))
#expect(!compactControls.contains("Self.workboardSubtitle("))
#expect(!compactControls.contains("Label(\"Refresh\""))
#expect(compactControls.contains("Label(\"Dispatch\""))
}
@Test func skillWorkshopUsesKanbanLanesOnWideIPad() throws {
let source = try String(contentsOf: Self.iPadSkillWorkshopScreenSourceURL(), encoding: .utf8)
let previewSource = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
let content = try Self.extract(
source,
from: "private var proposalContent: some View",
to: "private var proposalBoard: some View")
let board = try Self.extract(
source,
from: "private var proposalBoard: some View",
to: "private var proposalList: some View")
#expect(content.contains("if self.isCompactWidth"))
#expect(content.contains("self.proposalList"))
#expect(content.contains("self.proposalBoard"))
#expect(!content.contains("self.proposalDetail"))
#expect(board.contains("ScrollView(.horizontal)"))
#expect(board.contains("IPadSkillProposalKanbanColumn("))
#expect(source.contains("private struct IPadSkillProposalKanbanCard"))
#expect(source.contains("static let defaultProposalStatusBoardLanes"))
#expect(source.contains("private func proposals(forLaneStatus status: String)"))
#expect(previewSource.contains("#Preview(\n \"Skill Workshop iPad kanban lanes\""))
#expect(previewSource.contains("private struct IPadSkillWorkshopKanbanPreview"))
#expect(previewSource.contains("IPadSkillProposalKanbanColumn("))
#expect(previewSource.contains("status: \"needs-review\""))
#expect(previewSource.contains("status: \"manual_QA\""))
}
@Test func compactTaskRowsHavePopulatedPhonePreviews() throws {
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Workboard phone queue rows\")"))
#expect(source.contains("#Preview(\"Skill Workshop phone queue rows\")"))
#expect(source.contains("private struct IPadWorkboardCompactRowsPreview"))
#expect(source.contains("private struct IPadSkillWorkshopCompactRowsPreview"))
#expect(source.contains("IPadWorkboardPreviewFixtures.cards"))
#expect(source.contains("IPadSkillWorkshopPreviewFixtures.proposals"))
}
@Test func taskScreenPreviewMatricesCoverPrimaryStates() throws {
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Workboard states\")"))
#expect(source.contains("private struct IPadWorkboardStatesPreview"))
#expect(source.contains("self.previewHeader(\"Connected\")"))
#expect(source.contains("self.previewHeader(\"Empty\")"))
#expect(source.contains("self.previewHeader(\"Loading\")"))
#expect(source.contains("self.previewHeader(\"Error\")"))
#expect(source.contains("title: \"Loading cards\""))
#expect(source.contains("title: \"Cards unavailable\""))
#expect(source.contains("IPadWorkboardKanbanColumn("))
#expect(source.contains("#Preview(\"Skill Workshop states\")"))
#expect(source.contains("private struct IPadSkillWorkshopStatesPreview"))
#expect(source.contains("self.previewHeader(\"Offline / Error\")"))
#expect(source.contains("title: \"No proposals\""))
#expect(source.contains("title: \"Workshop offline\""))
#expect(source.contains("title: \"Proposal unavailable\""))
#expect(source.contains("#Preview(\n \"Skill Workshop iPad kanban lanes\""))
#expect(source.contains("private struct IPadSkillWorkshopKanbanPreview"))
#expect(source.contains("\"needs-review\""))
#expect(source.contains("\"manual_QA\""))
}
@Test func activityPreviewMatrixCoversConnectionStates() throws {
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
#expect(source.contains("#Preview(\"Activity states\")"))
#expect(source.contains("private struct IPadActivityStatesPreview"))
#expect(source.contains("self.previewHeader(\"Connected\")"))
#expect(source.contains("self.previewHeader(\"Loading\")"))
#expect(source.contains("self.previewHeader(\"Empty\")"))
#expect(source.contains("self.previewHeader(\"Error\")"))
#expect(source.contains("title: \"Sessions unavailable\""))
#expect(source.contains("title: \"No recent sessions\""))
#expect(source.contains("title: \"Loading sessions\""))
}
@Test func routedFeatureScreensReuseSharedProComponents() throws {
let source = try Self.iPadTaskFeatureScreensSource()
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
#expect(source.contains("ProMetricGrid(metrics: self.metrics)"))
#expect(source.contains("ProPanelHeader("))
#expect(source.contains("ProStatusRow("))
#expect(!source.contains("private struct ProMetricGrid"))
#expect(!source.contains("private struct ProMetric"))
#expect(!source.contains("private struct ProPanelHeader"))
#expect(!source.contains("private struct ProStatusRow"))
#expect(!channelsSource.contains("private struct SettingsChannelPanelHeader"))
#expect(!channelsSource.contains("private struct SettingsChannelInfoRow"))
#expect(componentsSource.contains("struct ProMetricGrid"))
#expect(componentsSource.contains("struct ProPanelHeader"))
#expect(componentsSource.contains("struct ProStatusRow"))
}
@Test func activityScreenStaysSplitFromTaskFeatureScreens() throws {
let taskSource = try Self.iPadTaskFeatureScreensSource()
let activitySource = try String(contentsOf: Self.iPadActivityScreenSourceURL(), encoding: .utf8)
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
#expect(activitySource.contains("struct IPadActivityScreen: View"))
#expect(activitySource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
#expect(activitySource.contains("IPadSidebarScreenChrome("))
#expect(!taskSource.contains("struct IPadActivityScreen"))
#expect(!taskSource.contains("import OpenClawChatUI"))
#expect(projectSource.contains("IPadActivityScreen.swift in Sources"))
}
@Test func routedFeatureChromeStaysSplitFromTaskFeatureScreens() throws {
let taskSource = try Self.iPadTaskFeatureScreensSource()
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
#expect(chromeSource.contains("struct IPadSidebarScreenChrome<Content: View>: View"))
#expect(chromeSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
#expect(chromeSource.contains("OpenClawGatewayCompactPill()"))
#expect(!taskSource.contains("struct IPadSidebarScreenChrome"))
#expect(projectSource.contains("IPadSidebarScreenChrome.swift in Sources"))
}
@Test func routedFeatureChromeKeepsGatewayPillActionable() throws {
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
let featureSource = try Self.iPadTaskFeatureScreensSource()
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
#expect(chromeSource.contains("let gatewayAction: (() -> Void)?"))
#expect(chromeSource.contains("private var gatewayPill: some View"))
#expect(chromeSource.contains("Button(action: gatewayAction)"))
#expect(chromeSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
#expect(featureSource.matches(of: /gatewayAction: self\.openSettings/).count == 2)
#expect(rootSource.contains("IPadActivityScreen("))
#expect(rootSource
.matches(of: /IPadActivityScreen\([\s\S]*?openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/)
.count == 1)
}
@Test func routedGatewayPillsOpenGatewaySettings() throws {
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let agentSource = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1)
#expect(!rootSource.contains("showGatewayActions"))
#expect(!rootSource.contains("gatewayActionsDialog"))
#expect(overviewSource.contains("Button(action: self.openSettings)"))
#expect(overviewSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
#expect(agentSource.contains("let openSettings: (() -> Void)?"))
#expect(agentOverviewSource.contains("OpenClawGatewayCompactPill()"))
#expect(agentOverviewSource.contains("Button(action: openSettings)"))
#expect(rootSource
.matches(of: /AgentProTab\([\s\S]*?openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/)
.count >= 3)
#expect(chatSource.contains("let openSettings: (() -> Void)?"))
#expect(chatSource.contains("private var connectionPillButton: some View"))
#expect(docsSource.contains("let gatewayAction: (() -> Void)?"))
#expect(settingsSource.contains("NavigationLink(value: SettingsRoute.gateway)"))
#expect(rootSource.contains("case .settings:"))
#expect(rootSource.contains("SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)"))
#expect(rootSource.contains("directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway"))
#expect(rootSource.contains("SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)"))
#expect(settingsSource.contains("title: \"Channels / Integrations\""))
#expect(settingsSource.contains("route: .channels"))
#expect(channelsSource.contains("let gatewayAction: (() -> Void)?"))
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
#expect(channelsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
}
@Test func gatewaySettingsKeepsPairingTrustDiagnosticsAndTailscaleActions() throws {
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
let trustSource = try String(contentsOf: Self.gatewayTrustPromptAlertSourceURL(), encoding: .utf8)
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
#expect(sectionsSource.contains("var gatewayDestination: some View"))
#expect(sectionsSource.contains("self.gatewayActions"))
#expect(sectionsSource.contains("self.manualGatewayCard"))
#expect(sectionsSource.contains("self.gatewaySetupCard"))
#expect(sectionsSource.contains("self.discoveredGatewaysCard"))
#expect(sectionsSource.contains("self.gatewayAdvancedCard"))
#expect(sectionsSource.contains("title: \"Reconnect\""))
#expect(sectionsSource.contains("Task { await self.reconnectGateway() }"))
#expect(sectionsSource.contains("title: \"Diagnose\""))
#expect(sectionsSource.contains("Task { await self.runDiagnostics() }"))
#expect(sectionsSource.contains("title: \"Scan QR\""))
#expect(sectionsSource.contains("self.openGatewayQRScanner()"))
#expect(sectionsSource.contains("title: \"Connect\""))
#expect(sectionsSource.contains("Task { await self.applySetupCodeAndConnect() }"))
#expect(sectionsSource.contains("Task { await self.connect(gateway) }"))
#expect(sectionsSource.contains("tailnetWarningText"))
#expect(sectionsSource.contains("GatewayProblemBanner("))
#expect(sectionsSource.contains("Task { await self.handleGatewayProblemPrimaryAction(problem) }"))
#expect(actionsSource.contains("await self.gatewayController.connectLastKnown()"))
#expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"))
#expect(actionsSource.contains("self.gatewayController.restartDiscovery()"))
#expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()"))
#expect(actionsSource.contains("await TCPProbe.probe(host: trimmed, port: port"))
#expect(actionsSource.contains("Check Tailscale or LAN."))
#expect(actionsSource.contains("Tailscale is off on this device. Turn it on, then try again."))
#expect(actionsSource.contains("Run /pair approve in your OpenClaw chat"))
#expect(actionsSource.contains("self.resetOnboarding()"))
#expect(actionsSource.contains("self.gatewayController.trustRotatedGatewayCertificate(from: problem)"))
#expect(actionsSource.contains("await self.retryGatewayConnectionFromProblem()"))
#expect(settingsSource.contains("GatewayProblemDetailsSheet("))
#expect(settingsSource.contains("QRScannerView("))
#expect(trustSource.contains("Trust this gateway?"))
#expect(trustSource.contains("Trust and connect"))
#expect(controllerSource.contains("acceptPendingTrustPrompt()"))
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
}
@Test func gatewaySettingsPreviewMatrixCoversPrimaryStates() throws {
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
#expect(supportSource.contains("#Preview(\"Gateway settings states\")"))
#expect(supportSource.contains("private struct SettingsGatewayStatesPreview"))
#expect(supportSource.contains("self.stateSection(\"Connected\")"))
#expect(supportSource.contains("self.stateSection(\"Loading\")"))
#expect(supportSource.contains("self.stateSection(\"Empty\")"))
#expect(supportSource.contains("self.stateSection(\"Error\")"))
#expect(supportSource.contains("GatewayProblemBanner("))
#expect(supportSource.contains("kind: .pairingRequired"))
#expect(supportSource.contains("Run /pair approve in your OpenClaw chat"))
#expect(supportSource.contains("Tailscale is off on this device. Turn it on, then try again."))
#expect(supportSource.contains("self.previewButton(\"Scan QR\""))
#expect(supportSource.contains("self.previewButton(\"Connect\""))
#expect(supportSource.contains("self.previewButton(\"Reconnect\""))
#expect(supportSource.contains("self.previewButton(\"Diagnose\""))
}
@Test func nativeChatUsesGatewayTransport() throws {
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
#expect(chatSource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
#expect(channelsSource.contains("Message routing and external channel clients."))
#expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata"))
#expect(channelsSource.contains("label: \"ClickClack\""))
#expect(channelsSource.contains("Self-hosted chat bot routing."))
}
private static func rootTabsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/RootTabs.swift")
}
private static func phoneHubSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/RootTabsPhoneControlHub.swift")
}
private static func proComponentsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/OpenClawProComponents.swift")
}
private static func commandCenterSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/CommandCenterTab.swift")
}
private static func agentProTabSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/AgentProTab.swift")
}
private static func agentProTabOverviewSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/AgentProTab+Overview.swift")
}
private static func agentProTabDestinationsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/AgentProTab+Destinations.swift")
}
private static func agentProNodesDestinationSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/AgentProNodesDestination.swift")
}
private static func agentProDreamingDestinationSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/AgentProDreamingDestination.swift")
}
private static func rootTabsNavigationSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/RootTabsNavigation.swift")
}
private static func iPadSidebarFeatureScreensSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/IPadSidebarFeatureScreens.swift")
}
private static func iPadTaskFeatureScreensSource() throws -> String {
try [
self.iPadWorkboardScreenSourceURL(),
self.iPadSkillWorkshopScreenSourceURL(),
self.iPadSidebarFeatureScreensSourceURL(),
]
.map { try String(contentsOf: $0, encoding: .utf8) }
.joined(separator: "\n")
}
private static func iPadWorkboardScreenSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/IPadWorkboardScreen.swift")
}
private static func iPadSkillWorkshopScreenSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/IPadSkillWorkshopScreen.swift")
}
private static func iPadSidebarFeaturePreviewsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/IPadSidebarFeaturePreviews.swift")
}
private static func iPadActivityScreenSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/IPadActivityScreen.swift")
}
private static func iPadSidebarScreenChromeSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/IPadSidebarScreenChrome.swift")
}
private static func chatProTabSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/ChatProTab.swift")
}
private static func docsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/OpenClawDocsScreen.swift")
}
private static func settingsProTabSectionsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/SettingsProTabSections.swift")
}
private static func settingsProTabSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
}
private static func settingsProTabActionsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/SettingsProTabActions.swift")
}
private static func settingsProTabSupportSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/SettingsProTabSupport.swift")
}
private static func gatewayTrustPromptAlertSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Gateway/GatewayTrustPromptAlert.swift")
}
private static func gatewayConnectionControllerSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Gateway/GatewayConnectionController.swift")
}
private static func channelsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Design/SettingsChannelsDestination.swift")
}
private static func sharedChatPreviewSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift")
}
private static func xcodeProjectSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("OpenClaw.xcodeproj/project.pbxproj")
}
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
let startRange = try #require(source.range(of: start))
let tail = source[startRange.lowerBound...]
let endRange = try #require(tail.range(of: end))
return String(tail[..<endRange.lowerBound])
}
}

View File

@@ -5,8 +5,9 @@ import UIKit
@testable import OpenClaw
@Suite struct SwiftUIRenderSmokeTests {
@MainActor private static func host(_ view: some View) -> UIWindow {
let window = UIWindow(frame: UIScreen.main.bounds)
@MainActor private static func host(_ view: some View, size: CGSize? = nil) -> UIWindow {
let frame = CGRect(origin: .zero, size: size ?? UIScreen.main.bounds.size)
let window = UIWindow(frame: frame)
window.rootViewController = UIHostingController(rootView: view)
window.makeKeyAndVisible()
window.rootViewController?.view.setNeedsLayout()
@@ -41,18 +42,102 @@ import UIKit
}
}
@Test @MainActor func rootTabsBuildAViewHierarchy() {
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
@Test @MainActor func rootTabsBuildsDeviceOrientationShellMatrix() {
for scenario in Self.rootTabsShellScenarios() {
let appModel = NodeAppModel()
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = RootTabs()
let root = RootTabs()
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
.environment(\.rootTabsUserInterfaceIdiomOverride, scenario.idiom)
.environment(\.horizontalSizeClass, scenario.horizontalSizeClass)
.environment(\.verticalSizeClass, scenario.verticalSizeClass)
_ = Self.host(root, size: scenario.size)
}
}
@Test @MainActor func rootTabsBuildGatewayStateViewHierarchies() {
for appModel in Self.rootTabsGatewayStateModels() {
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let root = RootTabs()
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
_ = Self.host(root)
}
}
@Test @MainActor func phoneControlHubBuildsGatewayStateViewHierarchies() {
for appModel in Self.rootTabsGatewayStateModels() {
let root = RootTabsPhoneControlHub(
groups: RootTabs.phoneControlGroups,
initialDestination: nil,
openRootDestination: { _ in })
.environment(appModel)
_ = Self.host(root)
}
}
@Test @MainActor func phoneControlHubBuildsLandscapeCompactState() {
let appModel = NodeAppModel()
let root = RootTabsPhoneControlHub(
groups: RootTabs.phoneControlGroups,
initialDestination: nil,
openRootDestination: { _ in })
.environment(appModel)
.environment(appModel.voiceWake)
.environment(gatewayController)
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
_ = Self.host(root)
}
@Test @MainActor func routedSidebarScreensBuildOfflineStates() {
let appModel = NodeAppModel()
let screens: [AnyView] = [
AnyView(CommandCenterTab(openChat: {}, openSettings: {})),
AnyView(IPadActivityScreen(openChat: {}, openSettings: {})),
AnyView(OpenClawDocsScreen()),
AnyView(SettingsChannelsScreen()),
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
AnyView(AgentProTab(directRoute: .agents)),
AnyView(AgentProTab(directRoute: .instances)),
AnyView(CommandSessionsScreen(openChat: {})),
AnyView(AgentProTab(directRoute: .dreaming)),
AnyView(AgentProTab(directRoute: .usage)),
AnyView(AgentProTab(directRoute: .cron)),
]
for screen in screens {
let root = NavigationStack { screen }
.environment(appModel)
_ = Self.host(root)
}
}
@Test @MainActor func taskScreensBuildPhoneLandscapeCompactStates() {
let appModel = NodeAppModel()
let screens: [AnyView] = [
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
]
for screen in screens {
let root = NavigationStack { screen }
.environment(appModel)
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
_ = Self.host(root)
}
}
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
let appModel = NodeAppModel()
let root = NavigationStack { VoiceWakeWordsSettingsView() }
@@ -64,4 +149,51 @@ import UIKit
let root = VoiceWakeToast(command: "openclaw: do something")
_ = Self.host(root)
}
@MainActor private static func rootTabsGatewayStateModels() -> [NodeAppModel] {
let offlineModel = NodeAppModel()
let connectingModel = NodeAppModel()
connectingModel.gatewayStatusText = "Connecting..."
let connectedModel = NodeAppModel()
connectedModel.enterAppleReviewDemoMode()
let errorModel = NodeAppModel()
errorModel.gatewayStatusText = "Gateway error: connection refused"
return [offlineModel, connectingModel, connectedModel, errorModel]
}
private static func rootTabsShellScenarios() -> [RootTabsShellScenario] {
[
RootTabsShellScenario(
idiom: .phone,
size: CGSize(width: 393, height: 852),
horizontalSizeClass: .compact,
verticalSizeClass: .regular),
RootTabsShellScenario(
idiom: .phone,
size: CGSize(width: 852, height: 393),
horizontalSizeClass: .regular,
verticalSizeClass: .compact),
RootTabsShellScenario(
idiom: .pad,
size: CGSize(width: 1024, height: 1366),
horizontalSizeClass: .regular,
verticalSizeClass: .regular),
RootTabsShellScenario(
idiom: .pad,
size: CGSize(width: 1366, height: 1024),
horizontalSizeClass: .regular,
verticalSizeClass: .regular),
]
}
private struct RootTabsShellScenario {
let idiom: UIUserInterfaceIdiom
let size: CGSize
let horizontalSizeClass: UserInterfaceSizeClass
let verticalSizeClass: UserInterfaceSizeClass
}
}

View File

@@ -97,7 +97,7 @@ targets:
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)"
TARGETED_DEVICE_FAMILY: "1"
TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
SUPPORTS_LIVE_ACTIVITIES: YES
@@ -183,7 +183,7 @@ targets:
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)"
TARGETED_DEVICE_FAMILY: "1"
TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
info:
@@ -220,7 +220,7 @@ targets:
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)"
TARGETED_DEVICE_FAMILY: "1"
TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
SUPPORTS_LIVE_ACTIVITIES: YES

View File

@@ -0,0 +1,256 @@
import Foundation
import OpenClawKit
import SwiftUI
private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
enum Scenario {
case connected
case empty
case loading
case error
}
let scenario: Scenario
init(scenario: Scenario = .connected) {
self.scenario = scenario
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
switch self.scenario {
case .connected:
break
case .empty:
return OpenClawChatHistoryPayload(
sessionKey: sessionKey,
sessionId: "preview-empty-session",
messages: [],
thinkingLevel: "medium")
case .loading:
try await Task.sleep(nanoseconds: 60_000_000_000)
return OpenClawChatHistoryPayload(
sessionKey: sessionKey,
sessionId: "preview-loading-session",
messages: [],
thinkingLevel: "medium")
case .error:
throw NSError(
domain: "OpenClawChatPreviewTransport",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Gateway not connected. Check Tailscale and retry."])
}
return OpenClawChatHistoryPayload(
sessionKey: sessionKey,
sessionId: "preview-session",
messages: [
Self.message(
role: "user",
text: "Can you check the gateway status and summarize anything risky?",
timestamp: 1),
Self.message(
role: "assistant",
text: "Gateway is reachable. The only notable item is that push relay is still using local distribution, so device tests should stay on the local lane.",
timestamp: 2),
Self.toolCall(
id: "tool-preview-1",
name: "gateway.status",
arguments: ["deep": AnyCodable(true)],
timestamp: 3),
Self.toolResult(
toolCallId: "tool-preview-1",
name: "gateway.status",
text: "status=ok, channels=ios,macos, lastHeartbeat=12s",
timestamp: 4),
],
thinkingLevel: "medium")
}
func listModels() async throws -> [OpenClawChatModelChoice] {
[
OpenClawChatModelChoice(
modelID: "gpt-5.5",
name: "GPT-5.5",
provider: "openai",
contextWindow: 400_000),
OpenClawChatModelChoice(
modelID: "sonnet-4.6",
name: "Claude Sonnet 4.6",
provider: "anthropic",
contextWindow: 200_000),
]
}
func sendMessage(
sessionKey _: String,
message _: String,
thinking _: String,
idempotencyKey: String,
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
}
func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse {
OpenClawChatSessionsListResponse(
ts: 0,
path: nil,
count: 2,
defaults: OpenClawChatSessionsDefaults(
modelProvider: "openai",
model: "gpt-5.5",
contextTokens: 400_000,
thinkingLevels: [
OpenClawChatThinkingLevelOption(id: "off", label: "off"),
OpenClawChatThinkingLevelOption(id: "medium", label: "medium"),
OpenClawChatThinkingLevelOption(id: "high", label: "high"),
],
thinkingDefault: "medium",
mainSessionKey: "main"),
sessions: [
Self.session(key: "main", displayName: "Main", updatedAt: 2),
Self.session(key: "ios-preview", displayName: "iOS preview", updatedAt: 1),
])
}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
switch self.scenario {
case .connected, .empty, .loading:
true
case .error:
false
}
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
AsyncStream { continuation in
continuation.finish()
}
}
func setActiveSessionKey(_: String) async throws {}
private static func message(role: String, text: String, timestamp: Double) -> AnyCodable {
AnyCodable([
"role": role,
"content": [["type": "text", "text": text]],
"timestamp": timestamp,
])
}
private static func toolCall(
id: String,
name: String,
arguments: [String: AnyCodable],
timestamp: Double) -> AnyCodable
{
AnyCodable([
"role": "assistant",
"content": [
[
"type": "toolCall",
"id": id,
"name": name,
"arguments": AnyCodable(arguments),
],
],
"timestamp": timestamp,
])
}
private static func toolResult(
toolCallId: String,
name: String,
text: String,
timestamp: Double) -> AnyCodable
{
AnyCodable([
"role": "tool",
"content": [["type": "text", "text": text]],
"timestamp": timestamp,
"toolCallId": toolCallId,
"toolName": name,
])
}
private static func session(
key: String,
displayName: String,
updatedAt: Double) -> OpenClawChatSessionEntry
{
OpenClawChatSessionEntry(
key: key,
kind: nil,
displayName: displayName,
surface: "ios",
subject: nil,
room: nil,
space: nil,
updatedAt: updatedAt,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: "medium",
verboseLevel: nil,
inputTokens: 2500,
outputTokens: 900,
totalTokens: 3400,
modelProvider: "openai",
model: "gpt-5.5",
contextTokens: 400_000)
}
}
#Preview("Chat") {
OpenClawChatPreview(scenario: .connected)
}
#Preview("Chat connected") {
OpenClawChatPreview(scenario: .connected)
}
#Preview("Chat empty") {
OpenClawChatPreview(
scenario: .empty,
sessionKey: "empty-preview")
}
#Preview("Chat loading") {
OpenClawChatPreview(
scenario: .loading,
sessionKey: "loading-preview")
}
#Preview("Chat gateway error") {
OpenClawChatPreview(
scenario: .error,
sessionKey: "error-preview")
}
#Preview("Onboarding chat") {
OpenClawChatView(
viewModel: OpenClawChatViewModel(
sessionKey: "ios-preview",
transport: OpenClawChatPreviewTransport()),
showsSessionSwitcher: false,
style: .onboarding,
markdownVariant: .standard,
userAccent: .blue)
}
private struct OpenClawChatPreview: View {
let scenario: OpenClawChatPreviewTransport.Scenario
var sessionKey: String = "main"
var body: some View {
OpenClawChatView(
viewModel: OpenClawChatViewModel(
sessionKey: self.sessionKey,
transport: OpenClawChatPreviewTransport(scenario: self.scenario)),
showsSessionSwitcher: true,
style: .standard,
markdownVariant: .standard,
userAccent: .blue,
showsAssistantTrace: true)
}
}

View File

@@ -16,6 +16,7 @@ type GatewayOptions = JsonOptions & {
token?: string;
timeout?: string;
expectFinal?: boolean;
board?: string;
};
function writeJson(value: unknown): void {
@@ -203,10 +204,13 @@ export function registerWorkboardCli(params: { program: Command; store: Workboar
workboard
.command("dispatch")
.description("Promote ready cards and start worker runs through the Gateway")
.option("--board <id>", "Dispatch a single board")
.option("--json", "Print JSON", false),
).action(async (options: GatewayOptions) => {
try {
const result = await callWorkboardGateway("workboard.cards.dispatch", options, {});
const result = await callWorkboardGateway("workboard.cards.dispatch", options, {
boardId: options.board,
});
if (options.json) {
writeJson(result);
} else {
@@ -223,7 +227,7 @@ export function registerWorkboardCli(params: { program: Command; store: Workboar
) {
throw error;
}
const result = redactDispatchResult(await params.store.dispatch());
const result = redactDispatchResult(await params.store.dispatch({ boardId: options.board }));
if (options.json) {
writeJson({ ...result, gatewayUnavailable: true });
} else {

View File

@@ -112,6 +112,40 @@ describe("dispatchAndStartWorkboardCards", () => {
expect(run).toHaveBeenCalledOnce();
});
it("starts workers only for the selected board", async () => {
const store = new WorkboardStore(createMemoryStore());
const ops = await store.create({
title: "Ops worker",
status: "ready",
priority: "urgent",
boardId: "ops",
});
const product = await store.create({
title: "Product worker",
status: "ready",
priority: "urgent",
boardId: "product",
});
const run = vi.fn().mockResolvedValue({ runId: "run-ops" });
const result = await dispatchAndStartWorkboardCards({
store,
subagent: { run },
options: { now: 10, maxStarts: 3, boardId: "ops" },
});
expect(result.started).toEqual([expect.objectContaining({ cardId: ops.id })]);
expect(run).toHaveBeenCalledOnce();
expect(run.mock.calls[0]?.[0]).toMatchObject({
sessionKey: `subagent:workboard-ops-${ops.id}`,
lane: `workboard:ops:${ops.id}`,
});
await expect(store.get(product.id)).resolves.toMatchObject({
status: "ready",
metadata: { automation: { boardId: "product" } },
});
});
it("keeps claimed review cards in the owner running slot", async () => {
const store = new WorkboardStore(createMemoryStore());
const review = await store.create({

View File

@@ -15,6 +15,7 @@ export type WorkboardDispatchStartOptions = {
model?: string;
provider?: string;
ownerId?: string;
boardId?: string;
now?: number;
};
@@ -123,7 +124,11 @@ function sortReadyCards(a: WorkboardCard, b: WorkboardCard): number {
);
}
function selectStartableCards(cards: WorkboardCard[], limit: number): WorkboardCard[] {
function selectStartableCards(
cards: WorkboardCard[],
limit: number,
candidates: WorkboardCard[] = cards,
): WorkboardCard[] {
if (limit <= 0) {
return [];
}
@@ -140,7 +145,7 @@ function selectStartableCards(cards: WorkboardCard[], limit: number): WorkboardC
runningByOwner.set(owner, (runningByOwner.get(owner) ?? 0) + 1);
}
const selected: WorkboardCard[] = [];
for (const card of cards
for (const card of candidates
.filter((entry) => entry.status === "ready" && !entry.metadata?.claim && !cardIsArchived(entry))
.toSorted(sortReadyCards)) {
const owner = card.agentId ?? DEFAULT_DISPATCH_OWNER;
@@ -162,7 +167,8 @@ export async function dispatchAndStartWorkboardCards(params: {
options?: WorkboardDispatchStartOptions;
}): Promise<WorkboardDispatchAndStartResult> {
const now = params.options?.now ?? Date.now();
const dispatch = await params.store.dispatch(now);
const boardId = params.options?.boardId;
const dispatch = await params.store.dispatch({ now, boardId });
const maxStarts = normalizePositiveInteger(
params.options?.maxStarts,
DEFAULT_DISPATCH_MAX_STARTS,
@@ -171,8 +177,9 @@ export async function dispatchAndStartWorkboardCards(params: {
const startFailures: WorkboardStartFailure[] = [];
const model = params.options?.model?.trim() || DEFAULT_DISPATCH_MODEL;
const cards = await params.store.list();
const candidates = await params.store.list({ boardId });
for (const card of selectStartableCards(cards, maxStarts)) {
for (const card of selectStartableCards(cards, maxStarts, candidates)) {
const ownerId = params.options?.ownerId?.trim() || card.agentId || DEFAULT_DISPATCH_OWNER;
const sessionKey = buildSessionKey(card);
let token = "";

View File

@@ -218,6 +218,49 @@ describe("workboard gateway methods", () => {
});
});
it("dispatches workboard cards when gateway params are omitted", async () => {
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
};
const methods = new Map<string, RegisteredMethod>();
const run = vi.fn().mockResolvedValue({ runId: "run-card" });
const api = {
runtime: {
state: {
openKeyedStore: vi.fn(() => createMemoryStore()),
},
subagent: { run },
},
registerGatewayMethod: vi.fn(
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
methods.set(method, { handler, opts });
},
),
} as unknown as OpenClawPluginApi;
const store = new WorkboardStore(createMemoryStore());
const card = await store.create({
title: "Ready worker",
status: "ready",
priority: "urgent",
});
registerWorkboardGatewayMethods({ api, store });
const respond = vi.fn();
await methods.get("workboard.cards.dispatch")?.handler({ respond } as never);
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(respond.mock.calls[0]?.[1]).toMatchObject({
started: [expect.objectContaining({ cardId: card.id, runId: "run-card" })],
});
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: `subagent:workboard-default-${card.id}`,
}),
);
});
it("claims, heartbeats, and bulk-updates cards through gateway methods", async () => {
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];

View File

@@ -383,11 +383,18 @@ export function registerWorkboardGatewayMethods(params: {
api.registerGatewayMethod(
"workboard.cards.dispatch",
async ({ respond }) => {
async ({ params: requestParams, respond }) => {
try {
const boardId =
requestParams && typeof requestParams === "object" && "boardId" in requestParams
? requestParams.boardId
: undefined;
const result = await dispatchAndStartWorkboardCards({
store,
subagent: api.runtime.subagent,
options: {
boardId: typeof boardId === "string" ? boardId : undefined,
},
});
respond(true, {
...result,

View File

@@ -2419,6 +2419,35 @@ describe("WorkboardStore", () => {
);
});
it("scopes dispatch mutations by board", async () => {
const boards = createMemoryStore<PersistedWorkboardBoard>();
const store = new WorkboardStore(createMemoryStore(), { boards });
await store.upsertBoard({
id: "ops",
orchestration: { autoDecompose: true, autoDecomposePerDispatch: 1 },
});
await store.upsertBoard({
id: "product",
orchestration: { autoDecompose: true, autoDecomposePerDispatch: 1 },
});
const ops = await store.create({ title: "Ops rough", status: "triage", boardId: "ops" });
const product = await store.create({
title: "Product rough",
status: "triage",
boardId: "product",
});
const dispatch = await store.dispatch({ now: 10, boardId: "ops" });
expect(dispatch.orchestrated.map((card) => card.id)).toEqual([ops.id]);
await expect(store.get(ops.id)).resolves.toMatchObject({
metadata: { workerProtocol: expect.any(Object) },
});
await expect(store.get(product.id)).resolves.not.toMatchObject({
metadata: { workerProtocol: expect.any(Object) },
});
});
it("deletes board notification subscriptions with empty board metadata", async () => {
const store = new WorkboardStore(createMemoryStore(), {
boards: createMemoryStore<PersistedWorkboardBoard>(),

View File

@@ -210,6 +210,9 @@ export type WorkboardDispatchResult = {
export type WorkboardListOptions = {
boardId?: unknown;
};
export type WorkboardDispatchOptions = WorkboardListOptions & {
now?: unknown;
};
export type WorkboardBoardSummary = {
id: string;
name?: string;
@@ -4098,14 +4101,18 @@ export class WorkboardStore {
});
}
async dispatch(now = Date.now()): Promise<WorkboardDispatchResult> {
async dispatch(
input: number | WorkboardDispatchOptions = Date.now(),
): Promise<WorkboardDispatchResult> {
const now = typeof input === "number" ? input : normalizeTimestamp(input.now, Date.now());
const boardId = typeof input === "number" ? undefined : normalizeBoardId(input.boardId);
return await this.enqueueMutation(async () => {
const promoted: WorkboardCard[] = [];
const reclaimed: WorkboardCard[] = [];
const blocked: WorkboardCard[] = [];
const orchestrated: WorkboardCard[] = [];
const orchestratedByBoard = new Map<string, number>();
for (const card of await this.list()) {
for (const card of await this.list({ boardId })) {
let latest = await this.promoteDependencyReady(card.id, now);
const wasPromoted = latest.status !== card.status;
const claim = latest.metadata?.claim;
@@ -4179,14 +4186,14 @@ export class WorkboardStore {
latest = await this.recordDispatch(latest, now);
}
if (await this.shouldAutoOrchestrate(latest)) {
const boardId = cardBoardId(latest);
const board = await this.boardStore.lookup(boardId);
const latestBoardId = cardBoardId(latest);
const board = await this.boardStore.lookup(latestBoardId);
const cap = board?.board.orchestration?.autoDecomposePerDispatch ?? 3;
const boardCount = orchestratedByBoard.get(boardId) ?? 0;
const boardCount = orchestratedByBoard.get(latestBoardId) ?? 0;
if (boardCount < cap) {
latest = await this.recordOrchestrationCandidate(latest, now);
orchestrated.push(latest);
orchestratedByBoard.set(boardId, boardCount + 1);
orchestratedByBoard.set(latestBoardId, boardCount + 1);
}
}
if (wasPromoted && latest.status !== "blocked") {

View File

@@ -979,9 +979,18 @@ export function createWorkboardTools(params: {
label: "Workboard Dispatch",
description:
"Run one Workboard dispatcher pass: promote unblocked cards, reclaim expired claims, and block timed-out runs.",
parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => {
const result = await store.dispatch();
parameters: Type.Object(
{
boardId: Type.Optional(Type.String({ description: "Optional board id filter." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record =
rawParams && typeof rawParams === "object" && !Array.isArray(rawParams)
? (rawParams as Record<string, unknown>)
: {};
const result = await store.dispatch({ boardId: record.boardId });
return jsonResult({
...result,
promoted: result.promoted.map(redactClaimToken),