diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/152.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 000000000000..6ebca8dd49cd Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/167.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 000000000000..f6c12700cd4e Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/20.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 000000000000..4640d610ea1c Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/76.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 000000000000..4e63684a2de2 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json index 922e8c6d7315..ded24327aca1 100644 --- a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1 +1 @@ -{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"}]} \ No newline at end of file +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"}]} diff --git a/apps/ios/Sources/Design/AgentProDreamingDestination.swift b/apps/ios/Sources/Design/AgentProDreamingDestination.swift index 4e35665317fd..729bde3a2ea1 100644 --- a/apps/ios/Sources/Design/AgentProDreamingDestination.swift +++ b/apps/ios/Sources/Design/AgentProDreamingDestination.swift @@ -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 diff --git a/apps/ios/Sources/Design/AgentProNodesDestination.swift b/apps/ios/Sources/Design/AgentProNodesDestination.swift index f20b1bf6dfe4..1d8c59819f83 100644 --- a/apps/ios/Sources/Design/AgentProNodesDestination.swift +++ b/apps/ios/Sources/Design/AgentProNodesDestination.swift @@ -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) } diff --git a/apps/ios/Sources/Design/AgentProTab+Destinations.swift b/apps/ios/Sources/Design/AgentProTab+Destinations.swift index 6267e61d55fd..1b8fd48a2f4b 100644 --- a/apps/ios/Sources/Design/AgentProTab+Destinations.swift +++ b/apps/ios/Sources/Design/AgentProTab+Destinations.swift @@ -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, diff --git a/apps/ios/Sources/Design/AgentProTab+Overview.swift b/apps/ios/Sources/Design/AgentProTab+Overview.swift index 5ef313383a10..80cac8ccb975 100644 --- a/apps/ios/Sources/Design/AgentProTab+Overview.swift +++ b/apps/ios/Sources/Design/AgentProTab+Overview.swift @@ -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", diff --git a/apps/ios/Sources/Design/AgentProTab.swift b/apps/ios/Sources/Design/AgentProTab.swift index 07652c50d2ce..117911f11c7b 100644 --- a/apps/ios/Sources/Design/AgentProTab.swift +++ b/apps/ios/Sources/Design/AgentProTab.swift @@ -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] + } } diff --git a/apps/ios/Sources/Design/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift index a7006a804349..11e57bd7ed85 100644 --- a/apps/ios/Sources/Design/ChatProTab.swift +++ b/apps/ios/Sources/Design/ChatProTab.swift @@ -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 } diff --git a/apps/ios/Sources/Design/CommandCenterSupport.swift b/apps/ios/Sources/Design/CommandCenterSupport.swift index dca673f4a547..ba0d88963d6e 100644 --- a/apps/ios/Sources/Design/CommandCenterSupport.swift +++ b/apps/ios/Sources/Design/CommandCenterSupport.swift @@ -23,7 +23,7 @@ struct CommandPanel: 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) } } } diff --git a/apps/ios/Sources/Design/CommandCenterTab.swift b/apps/ios/Sources/Design/CommandCenterTab.swift index 2bc43eac1ca7..9d42c049565e 100644 --- a/apps/ios/Sources/Design/CommandCenterTab.swift +++ b/apps/ios/Sources/Design/CommandCenterTab.swift @@ -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) } diff --git a/apps/ios/Sources/Design/IPadActivityScreen.swift b/apps/ios/Sources/Design/IPadActivityScreen.swift new file mode 100644 index 000000000000..3b697e508eb2 --- /dev/null +++ b/apps/ios/Sources/Design/IPadActivityScreen.swift @@ -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 + } +} diff --git a/apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift b/apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift new file mode 100644 index 000000000000..30e3d13b4b1f --- /dev/null +++ b/apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift @@ -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: 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 diff --git a/apps/ios/Sources/Design/IPadSidebarFeatureScreens.swift b/apps/ios/Sources/Design/IPadSidebarFeatureScreens.swift new file mode 100644 index 000000000000..c560bfd44ed3 --- /dev/null +++ b/apps/ios/Sources/Design/IPadSidebarFeatureScreens.swift @@ -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." + } + } +} diff --git a/apps/ios/Sources/Design/IPadSidebarScreenChrome.swift b/apps/ios/Sources/Design/IPadSidebarScreenChrome.swift new file mode 100644 index 000000000000..df8cd21cfa45 --- /dev/null +++ b/apps/ios/Sources/Design/IPadSidebarScreenChrome.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct IPadSidebarScreenChrome: 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 + } +} diff --git a/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift b/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift new file mode 100644 index 000000000000..2c075b9086e0 --- /dev/null +++ b/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift @@ -0,0 +1,1218 @@ +import OpenClawKit +import SwiftUI + +struct IPadSkillWorkshopScreen: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + @State private var proposals: [IPadSkillProposal] = [] + @State private var selectedProposalID: String? + @State private var selectedAgentScopeID = "" + @State private var statusFilter = "pending" + @State private var query = "" + @State private var isLoading = false + @State private var inspectingProposalID: String? + @State private var busyAction: IPadSkillProposalAction? + @State private var errorText: String? + @State private var noticeText: String? + @State private var presentedProposalRoute: IPadSkillProposalSheetRoute? + let headerLeadingAction: OpenClawSidebarHeaderAction? + let openSettings: () -> Void + + init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, openSettings: @escaping () -> Void = {}) { + self.headerLeadingAction = headerLeadingAction + self.openSettings = openSettings + } + + var body: some View { + IPadSidebarScreenChrome( + title: "Skill Workshop", + subtitle: "Review and apply proposed skills.", + headerLeadingAction: self.headerLeadingAction, + gatewayAction: self.openSettings) + { + if self.isCompactWidth { + self.compactFiltersCard + } else { + ProMetricGrid(metrics: self.metrics) + self.filtersCard + } + self.proposalContent + } + .task(id: self.refreshID) { + await self.loadProposals(force: false) + } + .refreshable { + await self.loadProposals(force: true) + } + .onChange(of: self.statusFilter) { _, _ in + self.syncSelectedProposalIDForVisibleProposals() + } + .onChange(of: self.query) { _, _ in + self.syncSelectedProposalIDForVisibleProposals() + } + .sheet(item: self.$presentedProposalRoute) { route in + NavigationStack { + ScrollView { + self.presentedProposalDetail(proposalID: route.proposalID) + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.vertical, 16) + } + .background(OpenClawProBackground()) + .navigationTitle("Proposal") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + self.presentedProposalRoute = nil + } + } + } + } + } + } + + private var metrics: [ProMetric] { + [ + ProMetric( + icon: "clock", + title: "Pending", + value: "\(self.count("pending"))", + color: OpenClawBrand.warn), + ProMetric( + icon: "checkmark.circle", + title: "Applied", + value: "\(self.count("applied"))", + color: OpenClawBrand.ok), + ProMetric( + icon: "shield", + title: "Held", + value: "\(self.count("quarantined") + self.count("stale"))", + color: .secondary), + ] + } + + private var filtersCard: some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + VStack(alignment: .leading, spacing: 12) { + self.agentScopeMenu + self.proposalSearchField + Picker("Status", selection: self.$statusFilter) { + ForEach(Self.proposalStatusFilters, id: \.self) { filter in + Text(Self.proposalStatusFilterLabel(filter)).tag(filter) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .tint(OpenClawBrand.accent) + HStack(spacing: 8) { + Button { + Task { await self.loadProposals(force: true) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + .disabled(self.isLoading) + + if self.isLoading { + ProgressView().controlSize(.small) + } + } + if let noticeText { + Text(noticeText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.accent) + } + if let errorText { + Text(errorText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.warn) + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var compactFiltersCard: some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text("\(self.filteredProposals.count) proposals") + .font(.headline) + Text(self.statusFilterLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + if self.isLoading { + ProgressView().controlSize(.small) + } + } + + self.agentScopeMenu + Picker("Status", selection: self.$statusFilter) { + ForEach(Self.proposalStatusFilters, id: \.self) { filter in + Text(Self.proposalStatusFilterLabel(filter)).tag(filter) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .tint(OpenClawBrand.accent) + + self.proposalSearchField + + HStack(spacing: 8) { + Button { + Task { await self.loadProposals(force: true) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + .disabled(self.isLoading) + } + if let noticeText { + Text(noticeText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.accent) + } + if let errorText { + Text(errorText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.warn) + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var proposalSearchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + TextField("Search proposals", text: self.$query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.subheadline) + if !self.query.isEmpty { + Button { + self.query = "" + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + } + + private var statusMenu: some View { + HStack(spacing: 8) { + Text("Status") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Menu { + ForEach(Self.proposalStatusFilters, id: \.self) { filter in + Button(Self.proposalStatusFilterLabel(filter)) { + self.statusFilter = filter + } + } + } label: { + HStack(spacing: 6) { + Text(self.statusFilterLabel) + .font(.subheadline.weight(.semibold)) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + } + } + + private var agentScopeMenu: some View { + HStack(spacing: 8) { + Text("Agent") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Menu { + Button("Default agent") { + self.selectedAgentScopeID = "" + } + ForEach(self.agentScopeOptions, id: \.id) { option in + Button(option.title) { + self.selectedAgentScopeID = option.id + } + } + } label: { + HStack(spacing: 6) { + Text(self.agentScopeLabel) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + .disabled(self.agentScopeOptions.isEmpty) + .accessibilityLabel("Skill Workshop agent scope") + } + } + + private var neutralControlTint: Color { + Color.primary.opacity(0.55) + } + + private var proposalContent: some View { + VStack(alignment: .leading, spacing: 12) { + if self.filteredProposals.isEmpty { + ProCard(radius: OpenClawProMetric.cardRadius) { + ProStatusRow( + icon: self.canRead ? "hammer" : "wifi.slash", + title: self.canRead ? "No proposals" : "No proposals loaded", + detail: self.canRead + ? "New proposals will appear here when agents draft skills." + : "Connect from Settings to load Skill Workshop proposals.", + value: self.canRead ? "empty" : nil, + color: .secondary, + actionTitle: nil, + action: nil) + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } else { + if self.isCompactWidth { + VStack(alignment: .leading, spacing: 12) { + self.proposalList + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } else { + self.proposalBoard + } + } + } + } + + private var proposalBoard: some View { + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 12) { + ForEach(self.visibleProposalLaneStatuses, id: \.self) { status in + IPadSkillProposalKanbanColumn( + status: status, + proposals: self.proposals(forLaneStatus: status), + selectedProposalID: self.selectedProposalID, + inspectingProposalID: self.inspectingProposalID, + canApplyProposalMutations: self.canApplyProposalMutations, + busyAction: self.busyAction, + select: { proposal in + self.selectProposal( + proposal, + opensSheet: true, + forceInspect: false) + }, + inspect: { proposal in + self.selectProposal( + proposal, + opensSheet: true, + forceInspect: true) + }, + apply: { proposal in + Task { await self.run(.apply, proposal: proposal) } + }, + reject: { proposal in + Task { await self.run(.reject, proposal: proposal) } + }) + .frame(width: 282) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + .scrollIndicators(.visible) + } + + private var proposalList: some View { + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: "Queue", + value: "\(self.filteredProposals.count)", + actionTitle: nil, + action: nil) + ForEach(Array(self.filteredProposals.enumerated()), id: \.element.id) { index, proposal in + if index > 0 { + Divider().padding(.leading, 58) + } + Button { + self.selectProposal( + proposal, + opensSheet: self.isCompactWidth, + forceInspect: false) + } label: { + IPadSkillProposalRow( + proposal: proposal, + isSelected: proposal.id == self.selectedProposalID, + isBusy: self.inspectingProposalID == proposal.id) + } + .buttonStyle(.plain) + .contextMenu { + Button("Inspect") { + self.selectProposal( + proposal, + opensSheet: true, + forceInspect: true) + } + if proposal.status == "pending" { + Button("Apply") { + Task { await self.run(.apply, proposal: proposal) } + } + .disabled(!self.canApplyProposalMutations || self.busyAction != nil) + Button("Reject", role: .destructive) { + Task { await self.run(.reject, proposal: proposal) } + } + .disabled(!self.canApplyProposalMutations || self.busyAction != nil) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if proposal.status == "pending" { + Button("Apply") { + Task { await self.run(.apply, proposal: proposal) } + } + .tint(OpenClawBrand.ok) + .disabled(!self.canApplyProposalMutations || self.busyAction != nil) + Button("Reject", role: .destructive) { + Task { await self.run(.reject, proposal: proposal) } + } + .disabled(!self.canApplyProposalMutations || self.busyAction != nil) + } + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button("Inspect") { + self.selectProposal( + proposal, + opensSheet: true, + forceInspect: true) + } + .tint(OpenClawBrand.accent) + } + } + } + } + } + + @ViewBuilder + private func presentedProposalDetail(proposalID: String) -> some View { + if let proposal = proposal(withID: proposalID) { + self.proposalDetailCard(proposal) + } else { + ProCard(radius: OpenClawProMetric.cardRadius) { + ProStatusRow( + icon: "hammer", + title: "Proposal unavailable", + detail: "Return to the queue and choose another proposal.", + value: "missing", + color: .secondary, + actionTitle: nil, + action: nil) + } + } + } + + private func proposalDetailCard(_ proposal: IPadSkillProposal) -> some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + ProIconBadge(systemName: "hammer", color: proposal.statusColor) + VStack(alignment: .leading, spacing: 4) { + Text(proposal.title) + .font(.headline) + Text(proposal.description) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + ProValuePill(value: proposal.status, color: proposal.statusColor) + } + + if self.inspectingProposalID == proposal.id { + ProgressView().controlSize(.small) + } + + if let content = proposal.content, !content.isEmpty { + Text(content) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(16) + .textSelection(.enabled) + } else { + Text("Select refresh to load the proposal body.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if !proposal.supportFiles.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Support files") + .font(.subheadline.weight(.semibold)) + ForEach(proposal.supportFiles, id: \.path) { file in + Text(file.path) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + + if proposal.status == "pending" { + if self.isCompactWidth { + VStack(spacing: 8) { + self.proposalApplyButton(proposal) + self.proposalRejectButton(proposal) + self.proposalInspectButton(proposal) + } + } else { + HStack(spacing: 8) { + self.proposalApplyButton(proposal) + self.proposalRejectButton(proposal) + self.proposalInspectButton(proposal) + } + } + if !self.canApplyProposalMutations { + self.adminScopeNotice + } + } + } + } + } + + private func proposalApplyButton(_ proposal: IPadSkillProposal) -> some View { + Button { + Task { await self.run(.apply, proposal: proposal) } + } label: { + Label("Apply", systemImage: "checkmark.circle") + .frame(maxWidth: self.isCompactWidth ? .infinity : nil) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(!self.canApplyProposalMutations || self.busyAction != nil) + } + + private func proposalRejectButton(_ proposal: IPadSkillProposal) -> some View { + Button(role: .destructive) { + Task { await self.run(.reject, proposal: proposal) } + } label: { + Label("Reject", systemImage: "xmark.circle") + .frame(maxWidth: self.isCompactWidth ? .infinity : nil) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!self.canApplyProposalMutations || self.busyAction != nil) + } + + private func proposalInspectButton(_ proposal: IPadSkillProposal) -> some View { + Button { + Task { await self.inspect(proposalID: proposal.id, force: true) } + } label: { + Label("Inspect", systemImage: "doc.text.magnifyingglass") + .frame(maxWidth: self.isCompactWidth ? .infinity : nil) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(self.inspectingProposalID != nil) + } + + private var refreshID: String { + [ + self.canRead ? "connected" : "offline", + self.scenePhase == .active ? "active" : "inactive", + self.selectedAgentScopeID.isEmpty ? "default" : self.selectedAgentScopeID, + ].joined(separator: ":") + } + + private var canRead: Bool { + self.appModel.isOperatorGatewayConnected + } + + private var canWrite: Bool { + self.appModel.isOperatorGatewayConnected && !self.appModel.isAppleReviewDemoModeEnabled + } + + private var canApplyProposalMutations: Bool { + Self.shouldEnableProposalMutation( + canWrite: self.canWrite, + hasOperatorAdminScope: self.appModel.hasOperatorAdminScope) + } + + private var agentScopeOptions: [IPadSkillWorkshopAgentScope] { + let defaultID = Self.normalizedScopeID(self.appModel.gatewayDefaultAgentId) + return self.appModel.gatewayAgents + .filter { Self.normalizedScopeID($0.id) != defaultID } + .map { agent in + let name = Self.normalizedScopeID(agent.name) + return IPadSkillWorkshopAgentScope( + id: Self.normalizedScopeID(agent.id), + title: name.isEmpty ? agent.id : name) + } + .filter { !$0.id.isEmpty } + .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + } + + private var agentScopeLabel: String { + let selected = Self.normalizedScopeID(self.selectedAgentScopeID) + guard !selected.isEmpty else { return self.defaultAgentScopeLabel } + return self.agentScopeOptions.first(where: { $0.id == selected })?.title ?? selected + } + + private var defaultAgentScopeLabel: String { + let defaultID = Self.normalizedScopeID(self.appModel.gatewayDefaultAgentId) + if let match = appModel.gatewayAgents.first(where: { Self.normalizedScopeID($0.id) == defaultID }) { + let name = Self.normalizedScopeID(match.name) + return name.isEmpty ? "Default agent" : name + } + let activeName = Self.normalizedScopeID(self.appModel.activeAgentName) + return activeName.isEmpty ? "Default agent" : activeName + } + + private var selectedAgentParam: String? { + let selected = Self.normalizedScopeID(self.selectedAgentScopeID) + return selected.isEmpty ? nil : selected + } + + static func shouldEnableProposalMutation(canWrite: Bool, hasOperatorAdminScope: Bool) -> Bool { + canWrite && hasOperatorAdminScope + } + + private var adminScopeNotice: some View { + HStack(spacing: 8) { + Image(systemName: "lock.shield") + .foregroundStyle(OpenClawBrand.warn) + Text("Admin scope required.") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + } + .padding(.top, 2) + } + + private var isCompactWidth: Bool { + Self.usesCompactTaskFlow( + horizontalSizeClass: self.horizontalSizeClass, + verticalSizeClass: self.verticalSizeClass) + } + + static func usesCompactTaskFlow( + horizontalSizeClass: UserInterfaceSizeClass?, + verticalSizeClass: UserInterfaceSizeClass?) -> Bool + { + horizontalSizeClass == .compact || verticalSizeClass == .compact + } + + static let proposalStatusFilters = ["pending", "held", "applied", "rejected", "all"] + + static let defaultProposalStatusBoardLanes = ["pending", "quarantined", "stale", "applied", "rejected"] + + static func proposalStatusFilterLabel(_ filter: String) -> String { + switch filter { + case "pending": "Pending" + case "held": "Held" + case "applied": "Applied" + case "rejected": "Rejected" + default: "All" + } + } + + static func proposalLaneLabel(_ status: String) -> String { + switch status { + case "quarantined": "Quarantined" + case "stale": "Stale" + case "pending", "applied", "rejected": + self.proposalStatusFilterLabel(status) + default: + self.titleCasedProposalStatus(status) + } + } + + static func titleCasedProposalStatus(_ status: String) -> String { + status + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .split(separator: " ") + .map { word in + let text = String(word) + if text == text.uppercased() { + return text + } + return text.prefix(1).uppercased() + text.dropFirst().lowercased() + } + .joined(separator: " ") + } + + static func proposalStatusBoardLanes(filter: String, proposalStatuses: [String]) -> [String] { + let customStatuses = proposalStatuses + .filter { !self.defaultProposalStatusBoardLanes.contains($0) } + .sorted() + switch filter { + case "all": + return self.defaultProposalStatusBoardLanes + customStatuses + case "held": + return ["quarantined", "stale"] + case "pending", "applied", "rejected": + return [filter] + default: + return [filter] + } + } + + static func proposalStatusMatchesFilter(status: String, filter: String) -> Bool { + switch filter { + case "all": + true + case "held": + status == "quarantined" || status == "stale" + default: + status == filter + } + } + + static func nextSelectedProposalID( + current: String?, + proposals: [(id: String, status: String)], + filter: String) -> String? + { + let filtered = proposals.filter { Self.proposalStatusMatchesFilter(status: $0.status, filter: filter) } + return Self.nextSelectedProposalID(current: current, visibleProposalIDs: filtered.map(\.id)) + } + + static func nextSelectedProposalID(current: String?, visibleProposalIDs: [String]) -> String? { + guard !visibleProposalIDs.isEmpty else { return nil } + if let current, visibleProposalIDs.contains(current) { + return current + } + return visibleProposalIDs.first + } + + static func normalizedScopeID(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var statusFilterLabel: String { + Self.proposalStatusFilterLabel(self.statusFilter) + } + + private var filteredProposals: [IPadSkillProposal] { + let trimmedQuery = self.query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.proposals + .filter { proposal in + Self.proposalStatusMatchesFilter(status: proposal.status, filter: self.statusFilter) + } + .filter { proposal in + guard !trimmedQuery.isEmpty else { return true } + return [ + proposal.title, + proposal.description, + proposal.skillName, + proposal.skillKey, + ] + .joined(separator: " ") + .lowercased() + .contains(trimmedQuery) + } + } + + private var visibleProposalLaneStatuses: [String] { + Self.proposalStatusBoardLanes( + filter: self.statusFilter, + proposalStatuses: self.proposals.map(\.status)) + } + + private func proposals(forLaneStatus status: String) -> [IPadSkillProposal] { + let trimmedQuery = self.query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.proposals + .filter { $0.status == status } + .filter { proposal in + guard !trimmedQuery.isEmpty else { return true } + return [ + proposal.title, + proposal.description, + proposal.skillName, + proposal.skillKey, + ] + .joined(separator: " ") + .lowercased() + .contains(trimmedQuery) + } + .sorted { $0.updatedAtMs > $1.updatedAtMs } + } + + private func syncSelectedProposalIDForVisibleProposals() { + let nextID = Self.nextSelectedProposalID( + current: self.selectedProposalID, + visibleProposalIDs: self.filteredProposals.map(\.id)) + guard self.selectedProposalID != nextID else { return } + self.selectedProposalID = nextID + } + + private func count(_ status: String) -> Int { + self.proposals.count(where: { $0.status == status }) + } + + private func proposal(withID id: String) -> IPadSkillProposal? { + self.proposals.first { $0.id == id } + } + + private func selectProposal( + _ proposal: IPadSkillProposal, + opensSheet: Bool, + forceInspect: Bool) + { + self.selectedProposalID = proposal.id + if opensSheet { + self.presentedProposalRoute = IPadSkillProposalSheetRoute(proposalID: proposal.id) + } + Task { await self.inspect(proposalID: proposal.id, force: forceInspect) } + } + + private func loadProposals(force: Bool) async { + guard self.scenePhase == .active else { return } + guard self.canRead else { + self.proposals = [] + self.errorText = nil + return + } + guard !self.isLoading else { return } + + self.isLoading = true + self.errorText = nil + defer { self.isLoading = false } + + do { + let data = try await request( + method: "skills.proposals.list", + params: IPadSkillProposalListParams(agentId: selectedAgentParam), + timeoutSeconds: 20) + let response = try JSONDecoder().decode(IPadSkillProposalManifest.self, from: data) + let previousByID = Dictionary(uniqueKeysWithValues: proposals.map { ($0.id, $0) }) + let next = response.proposals + .map { IPadSkillProposal(entry: $0, previous: previousByID[$0.id]) } + .sorted { $0.updatedAtMs > $1.updatedAtMs } + self.proposals = next + self.syncSelectedProposalIDForVisibleProposals() + if let selectedProposalID { + await self.inspect(proposalID: selectedProposalID, force: force) + } + } catch { + if force || self.proposals.isEmpty { + self.errorText = Self.message(for: error) + } + } + } + + private func inspect(proposalID: String, force: Bool) async { + guard self.canRead else { return } + guard force || self.proposals.first(where: { $0.id == proposalID })?.content == nil else { return } + guard self.inspectingProposalID == nil else { return } + + self.inspectingProposalID = proposalID + self.errorText = nil + defer { self.inspectingProposalID = nil } + + do { + let data = try await request( + method: "skills.proposals.inspect", + params: IPadSkillProposalInspectParams( + agentId: selectedAgentParam, + proposalId: proposalID), + timeoutSeconds: 20) + let response = try JSONDecoder().decode(IPadSkillProposalInspectResponse.self, from: data) + self.merge(IPadSkillProposal(inspect: response, previous: self.proposals.first { $0.id == proposalID })) + } catch { + self.errorText = Self.message(for: error) + } + } + + private func run(_ action: IPadSkillProposalAction.Kind, proposal: IPadSkillProposal) async { + guard self.canApplyProposalMutations, self.busyAction == nil else { return } + self.busyAction = IPadSkillProposalAction(kind: action, proposalID: proposal.id) + self.errorText = nil + self.noticeText = nil + defer { self.busyAction = nil } + + do { + let method = action == .apply ? "skills.proposals.apply" : "skills.proposals.reject" + _ = try await self.request( + method: method, + params: IPadSkillProposalInspectParams( + agentId: self.selectedAgentParam, + proposalId: proposal.id), + timeoutSeconds: 30) + self.noticeText = action == .apply ? "Proposal applied." : "Proposal rejected." + await self.loadProposals(force: true) + } catch { + self.errorText = Self.message(for: error) + } + } + + private func merge(_ proposal: IPadSkillProposal) { + self.proposals.removeAll { $0.id == proposal.id } + self.proposals.append(proposal) + self.proposals.sort { $0.updatedAtMs > $1.updatedAtMs } + } + + private func request(method: String, params: some Encodable, timeoutSeconds: Int) async throws -> Data { + guard self.canRead else { throw IPadSidebarGatewayError.offline } + let data = try JSONEncoder().encode(params) + guard let json = String(data: data, encoding: .utf8) else { + throw IPadSidebarGatewayError.invalidPayload + } + return try await self.appModel.operatorSession.request( + method: method, + paramsJSON: json, + timeoutSeconds: timeoutSeconds) + } + + private static func message(for error: Error) -> String { + if let gatewayError = error as? IPadSidebarGatewayError { + return gatewayError.message + } + return error.localizedDescription + } +} + +struct IPadSkillProposalKanbanColumn: View { + let status: String + let proposals: [IPadSkillProposal] + let selectedProposalID: String? + let inspectingProposalID: String? + let canApplyProposalMutations: Bool + let busyAction: IPadSkillProposalAction? + let select: (IPadSkillProposal) -> Void + let inspect: (IPadSkillProposal) -> Void + let apply: (IPadSkillProposal) -> Void + let reject: (IPadSkillProposal) -> Void + + var body: some View { + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: IPadSkillWorkshopScreen.proposalLaneLabel(self.status), + value: "\(self.proposals.count)", + actionTitle: nil, + action: nil) + + if self.proposals.isEmpty { + ProStatusRow( + icon: "hammer", + title: "No \(IPadSkillWorkshopScreen.proposalLaneLabel(self.status).lowercased()) proposals", + detail: "Matching proposals appear here after gateway refresh.", + value: "empty", + color: .secondary, + actionTitle: nil, + action: nil) + } else { + ForEach(Array(self.proposals.enumerated()), id: \.element.id) { index, proposal in + if index > 0 { + Divider().padding(.leading, 12) + } + IPadSkillProposalKanbanCard( + proposal: proposal, + isSelected: proposal.id == self.selectedProposalID, + isInspecting: proposal.id == self.inspectingProposalID, + canApplyProposalMutations: self.canApplyProposalMutations, + isBusy: self.busyAction != nil, + select: { + self.select(proposal) + }, + inspect: { + self.inspect(proposal) + }, + apply: { + self.apply(proposal) + }, + reject: { + self.reject(proposal) + }) + } + } + } + } + } +} + +private struct IPadSkillProposalKanbanCard: View { + let proposal: IPadSkillProposal + let isSelected: Bool + let isInspecting: Bool + let canApplyProposalMutations: Bool + let isBusy: Bool + let select: () -> Void + let inspect: () -> Void + let apply: () -> Void + let reject: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Button(action: self.select) { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 10) { + ProIconBadge( + systemName: self.isInspecting ? "hourglass" : "hammer", + color: self.proposal.statusColor) + VStack(alignment: .leading, spacing: 4) { + Text(self.proposal.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(self.isSelected ? OpenClawBrand.accent : .primary) + .lineLimit(2) + Text(self.proposal.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + HStack(spacing: 8) { + ProValuePill(value: self.proposal.status, color: self.proposal.statusColor) + Spacer(minLength: 4) + Text(self.proposal.ageLabel) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + HStack(spacing: 8) { + if self.proposal.status == "pending" { + Button(action: self.apply) { + Image(systemName: "checkmark.circle") + } + .accessibilityLabel("Apply Proposal") + .buttonStyle(.bordered) + .controlSize(.mini) + .disabled(!self.canApplyProposalMutations || self.isBusy) + + Button(role: .destructive, action: self.reject) { + Image(systemName: "xmark.circle") + } + .accessibilityLabel("Reject Proposal") + .buttonStyle(.bordered) + .controlSize(.mini) + .disabled(!self.canApplyProposalMutations || self.isBusy) + } + + Button(action: self.inspect) { + Image(systemName: "doc.text.magnifyingglass") + } + .accessibilityLabel("Inspect Proposal") + .buttonStyle(.bordered) + .controlSize(.mini) + .disabled(self.isInspecting) + } + } + .padding(12) + .background( + self.isSelected ? OpenClawBrand.accent.opacity(0.08) : Color.clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .contentShape(Rectangle()) + .contextMenu { + Button("Inspect", action: self.inspect) + if self.proposal.status == "pending" { + Button("Apply", action: self.apply) + .disabled(!self.canApplyProposalMutations || self.isBusy) + Button("Reject", role: .destructive, action: self.reject) + .disabled(!self.canApplyProposalMutations || self.isBusy) + } + } + } +} + +struct IPadSkillProposalRow: View { + let proposal: IPadSkillProposal + let isSelected: Bool + let isBusy: Bool + + var body: some View { + HStack(alignment: .top, spacing: 12) { + ProIconBadge(systemName: self.isBusy ? "hourglass" : "hammer", color: self.proposal.statusColor) + VStack(alignment: .leading, spacing: 4) { + Text(self.proposal.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(self.isSelected ? OpenClawBrand.accent : .primary) + .lineLimit(1) + Text(self.proposal.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 8) + Text(self.proposal.ageLabel) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + self.isSelected ? Color.red.opacity(0.08) : Color.clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +private struct IPadSkillProposalSheetRoute: Identifiable { + let proposalID: String + + var id: String { + self.proposalID + } +} + +struct IPadSkillProposalAction: Equatable { + enum Kind { + case apply + case reject + } + + let kind: Kind + let proposalID: String +} + +private struct IPadSkillProposalManifest: Decodable { + let proposals: [IPadSkillProposalManifestEntry] +} + +struct IPadSkillProposalManifestEntry: Decodable { + let id: String + let kind: String + let status: String + let title: String + let description: String + let skillName: String + let skillKey: String + let createdAt: String + let updatedAt: String + let scanState: String +} + +private struct IPadSkillWorkshopAgentScope: Identifiable { + let id: String + let title: String +} + +private struct IPadSkillProposalListParams: Encodable { + let agentId: String? +} + +private struct IPadSkillProposalInspectParams: Encodable { + let agentId: String? + let proposalId: String +} + +struct IPadSkillProposalInspectResponse: Decodable { + let record: IPadSkillProposalRecord + let content: String + let supportFiles: [IPadSkillProposalSupportFile]? +} + +struct IPadSkillProposalRecord: Decodable { + let id: String + let kind: String + let status: String + let title: String + let description: String + let createdAt: String + let updatedAt: String + let proposedVersion: String + let target: IPadSkillProposalTarget +} + +struct IPadSkillProposalTarget: Decodable { + let skillName: String + let skillKey: String +} + +struct IPadSkillProposalSupportFile: Decodable { + let path: String + let content: String? +} + +struct IPadSkillProposal: Identifiable { + let id: String + let kind: String + let status: String + let title: String + let description: String + let skillName: String + let skillKey: String + let createdAtMs: Double + let updatedAtMs: Double + var content: String? + var supportFiles: [IPadSkillProposalSupportFile] + + init(entry: IPadSkillProposalManifestEntry, previous: IPadSkillProposal?) { + self.id = entry.id + self.kind = entry.kind + self.status = entry.status + self.title = entry.title.isEmpty ? entry.skillName : entry.title + self.description = entry.description + self.skillName = entry.skillName + self.skillKey = entry.skillKey + self.createdAtMs = Self.parseDate(entry.createdAt) + self.updatedAtMs = Self.parseDate(entry.updatedAt) + self.content = previous?.updatedAtMs == self.updatedAtMs ? previous?.content : nil + self.supportFiles = previous?.updatedAtMs == self.updatedAtMs ? previous?.supportFiles ?? [] : [] + } + + init(inspect: IPadSkillProposalInspectResponse, previous: IPadSkillProposal?) { + let record = inspect.record + self.id = record.id + self.kind = record.kind + self.status = record.status + self.title = record.title.isEmpty ? record.target.skillName : record.title + self.description = record.description + self.skillName = record.target.skillName + self.skillKey = record.target.skillKey + self.createdAtMs = Self.parseDate(record.createdAt) + self.updatedAtMs = Self.parseDate(record.updatedAt) + self.content = Self.stripFrontmatter(inspect.content) + self.supportFiles = inspect.supportFiles ?? previous?.supportFiles ?? [] + } + + var ageLabel: String { + let diff = max(0, Date().timeIntervalSince1970 * 1000 - self.updatedAtMs) + let minutes = Int(diff / 60000) + if minutes < 1 { return "now" } + if minutes < 60 { return "\(minutes)m" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h" } + return "\(hours / 24)d" + } + + var statusColor: Color { + switch self.status { + case "pending": OpenClawBrand.warn + case "applied": OpenClawBrand.ok + case "rejected": .secondary + case "quarantined", "stale": OpenClawBrand.warn + default: OpenClawBrand.accent + } + } + + private static func parseDate(_ value: String) -> Double { + (ISO8601DateFormatter().date(from: value)?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000 + } + + private static func stripFrontmatter(_ value: String) -> String { + let pattern = #"(?s)^---\r?\n.*?\r?\n---\r?\n?"# + return value.replacingOccurrences(of: pattern, with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/apps/ios/Sources/Design/IPadWorkboardScreen.swift b/apps/ios/Sources/Design/IPadWorkboardScreen.swift new file mode 100644 index 000000000000..f24197d68852 --- /dev/null +++ b/apps/ios/Sources/Design/IPadWorkboardScreen.swift @@ -0,0 +1,1395 @@ +import OpenClawKit +import SwiftUI + +struct IPadWorkboardScreen: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + @State private var cards: [IPadWorkboardCard] = [] + @State private var statuses: [String] = IPadWorkboardDefaults.statuses + @State private var selectedStatus = "active" + @State private var selectedBoardID = "" + @State private var knownBoardIDs: [String] = [] + @State private var query = "" + @State private var isLoading = false + @State private var errorText: String? + @State private var draftTitle = "" + @State private var draftNotes = "" + @State private var isCreatingCard = false + @State private var busyCardID: String? + @State private var dispatchSummaryText: String? + @State private var presentedSheet: IPadWorkboardSheet? + 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: "Workboard", + subtitle: self.currentWorkboardSubtitle, + headerLeadingAction: self.headerLeadingAction, + gatewayAction: self.openSettings) + { + if self.isCompactWidth { + self.compactQueueControls + self.compactCardsPanel + } else { + ProMetricGrid(metrics: self.metrics) + self.controlsCard + self.kanbanBoard + } + } + .task(id: self.refreshID) { + await self.loadCards(force: false) + } + .refreshable { + await self.loadCards(force: true) + } + .sheet(item: self.$presentedSheet) { sheet in + switch sheet { + case .create: + NavigationStack { + self.createCardSheet + } + case let .card(card): + IPadWorkboardCardDetailSheet( + card: card, + statuses: self.statuses, + isBusy: self.busyCardID == card.id, + canWrite: self.canWrite, + openSession: { self.open(card) }, + move: { status in Task { await self.move(card, to: status) } }, + archive: { Task { await self.archive(card) } }) + } + } + } + + private var metrics: [ProMetric] { + [ + ProMetric( + icon: "tray.full", + title: "Cards", + value: "\(self.cards.count)", + color: OpenClawBrand.accent), + ProMetric( + icon: "figure.run", + title: "Running", + value: "\(self.cards.count(where: { $0.status == "running" }))", + color: OpenClawBrand.ok), + ProMetric( + icon: "exclamationmark.triangle", + title: "Blocked", + value: "\(self.cards.count(where: { $0.status == "blocked" }))", + color: OpenClawBrand.warn), + ] + } + + private var controlsCard: some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + VStack(alignment: .leading, spacing: 12) { + self.boardScopeMenu + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + TextField("Search cards", text: self.$query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.subheadline) + if !self.query.isEmpty { + Button { + self.query = "" + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + if self.isCompactWidth { + self.statusMenu + } else { + Picker("Scope", selection: self.$selectedStatus) { + Text("Active").tag("active") + ForEach(self.statuses, id: \.self) { status in + Text(IPadWorkboardDefaults.label(for: status)).tag(status) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .tint(OpenClawBrand.accent) + } + + HStack(spacing: 8) { + self.newCardButton(expands: false) + + Button { + Task { await self.dispatchCards() } + } label: { + Label("Dispatch", systemImage: "bolt.fill") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!self.canWrite || self.isLoading) + + Button { + Task { await self.loadCards(force: true) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + .disabled(self.isLoading) + + if self.isLoading { + ProgressView().controlSize(.small) + } + } + + if let dispatchSummaryText { + Text(dispatchSummaryText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.accent) + } + if let errorText { + Text(errorText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.warn) + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var compactQueueControls: some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("\(self.filteredCards.count) cards") + .font(.headline) + Spacer(minLength: 8) + self.compactRefreshButton + } + + self.compactBoardScopeMenu + self.compactStatusPicker + + if self.canWrite { + HStack(spacing: 8) { + self.newCardButton(expands: true) + + Button { + Task { await self.dispatchCards() } + } label: { + Label("Dispatch", systemImage: "bolt.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(self.isLoading) + } + } else { + Text(Self.compactWriteUnavailableMessage(canRead: self.canRead)) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + if let dispatchSummaryText { + Text(dispatchSummaryText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.accent) + } + if let errorText { + Text(errorText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.warn) + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var compactRefreshButton: some View { + Button { + Task { await self.loadCards(force: true) } + } label: { + Image(systemName: "arrow.clockwise") + .font(.caption.weight(.semibold)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .foregroundStyle(self.neutralControlTint) + .accessibilityLabel("Refresh workboard") + .disabled(self.isLoading) + } + + private func newCardButton(expands: Bool) -> some View { + Button { + self.beginCreateCard() + } label: { + Label("New Card", systemImage: "plus") + .frame(maxWidth: expands ? .infinity : nil) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(self.isCreatingCard) + .accessibilityHint("Opens card title and notes entry") + } + + private var compactBoardScopeMenu: some View { + Menu { + Button("All boards") { + self.selectedBoardID = "" + } + ForEach(self.boardScopeOptions, id: \.self) { boardID in + Button(Self.boardScopeLabel(for: boardID)) { + self.selectedBoardID = boardID + } + } + } label: { + HStack(spacing: 8) { + Image(systemName: "rectangle.stack") + .font(.caption.weight(.semibold)) + Text(self.boardScopeLabel) + .font(.caption.weight(.semibold)) + .lineLimit(1) + Spacer(minLength: 4) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + } + .padding(.horizontal, 10) + .frame(height: 32) + .background(Color.primary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + .accessibilityLabel("Workboard board scope") + } + + private var compactStatusPicker: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + self.compactStatusChip("active") + ForEach(self.compactStatuses, id: \.self) { status in + self.compactStatusChip(status) + } + } + .padding(.vertical, 1) + } + .scrollIndicators(.hidden) + .overlay(alignment: .trailing) { + LinearGradient( + colors: [.clear, Color(uiColor: .secondarySystemGroupedBackground)], + startPoint: .leading, + endPoint: .trailing) + .frame(width: 24) + .allowsHitTesting(false) + } + } + + private func compactStatusChip(_ status: String) -> some View { + Button { + self.selectedStatus = status + } label: { + Text(IPadWorkboardDefaults.label(for: status)) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .padding(.horizontal, 10) + .frame(height: 30) + .background( + self.selectedStatus == status + ? OpenClawBrand.accent.opacity(0.12) + : Color.primary.opacity(0.06), + in: Capsule()) + .overlay { + Capsule() + .strokeBorder( + self.selectedStatus == status + ? OpenClawBrand.accent.opacity(0.42) + : Color.primary.opacity(0.08), + lineWidth: 1) + } + } + .buttonStyle(.plain) + .foregroundStyle(self.selectedStatus == status ? OpenClawBrand.accent : .primary) + .accessibilityLabel("Show \(IPadWorkboardDefaults.label(for: status)) cards") + } + + private var boardScopeMenu: some View { + HStack(spacing: 8) { + Text("Board") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Menu { + Button("All boards") { + self.selectedBoardID = "" + } + ForEach(self.boardScopeOptions, id: \.self) { boardID in + Button(Self.boardScopeLabel(for: boardID)) { + self.selectedBoardID = boardID + } + } + } label: { + HStack(spacing: 6) { + Text(self.boardScopeLabel) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + .accessibilityLabel("Workboard board scope") + } + } + + private var statusMenu: some View { + HStack(spacing: 8) { + Text("Status") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Menu { + Button("Active") { + self.selectedStatus = "active" + } + ForEach(self.statuses, id: \.self) { status in + Button(IPadWorkboardDefaults.label(for: status)) { + self.selectedStatus = status + } + } + } label: { + HStack(spacing: 6) { + Text(IPadWorkboardDefaults.label(for: self.selectedStatus)) + .font(.subheadline.weight(.semibold)) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + } + } + + private var neutralControlTint: Color { + Color.primary.opacity(0.55) + } + + private var kanbanBoard: some View { + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 12) { + ForEach(self.visibleKanbanStatuses, id: \.self) { status in + IPadWorkboardKanbanColumn( + status: status, + cards: self.cards(forKanbanStatus: status), + statuses: self.statuses, + busyCardID: self.busyCardID, + openSession: { card in + self.open(card) + }, + inspect: { card in + self.presentedSheet = .card(card) + }, + move: { card, status in + Task { await self.move(card, to: status) } + }, + archive: { card in + Task { await self.archive(card) } + }) + .frame(width: 282) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.bottom, 12) + } + .scrollIndicators(.visible) + } + + private var compactCardsPanel: some View { + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: "Queue", + value: "\(self.filteredCards.count)", + actionTitle: nil, + action: nil) + if self.filteredCards.isEmpty { + ProStatusRow( + icon: self.canRead ? "tray" : "wifi.slash", + title: self.canRead ? "No cards" : "No cards loaded", + detail: self.canRead + ? "Create a card or change the filter." + : "Connect from Settings to load workboard cards.", + value: self.canRead ? "empty" : nil, + color: .secondary, + actionTitle: nil, + action: nil) + } else { + ForEach(Array(self.filteredCards.enumerated()), id: \.element.id) { index, card in + if index > 0 { + Divider().padding(.leading, 58) + } + IPadWorkboardQueueRow( + card: card, + statuses: self.statuses, + isBusy: self.busyCardID == card.id, + inspect: { + self.presentedSheet = .card(card) + }, + openSession: { + self.open(card) + }, + move: { status in + Task { await self.move(card, to: status) } + }, + archive: { + Task { await self.archive(card) } + }) + } + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var createCardSheet: some View { + Form { + Section("Card") { + TextField("Title", text: self.$draftTitle) + .textInputAutocapitalization(.sentences) + .submitLabel(.next) + TextField("Notes", text: self.$draftNotes, axis: .vertical) + .lineLimit(3...6) + .textInputAutocapitalization(.sentences) + } + if let errorText { + Section { + Text(errorText) + .foregroundStyle(OpenClawBrand.warn) + } + } + } + .navigationTitle("New Card") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.presentedSheet = nil + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + if await self.createCard() { + self.presentedSheet = nil + } + } + } label: { + Text(self.isCreatingCard ? "Creating..." : "Create") + } + .disabled(self.isCreatingCard) + .accessibilityHint(self.createUnavailableMessage ?? "Creates a workboard card") + } + } + } + + private var refreshID: String { + [ + self.canRead ? "connected" : "offline", + self.scenePhase == .active ? "active" : "inactive", + self.selectedBoardID.isEmpty ? "all" : self.selectedBoardID, + ].joined(separator: ":") + } + + private var canRead: Bool { + self.appModel.isOperatorGatewayConnected + } + + private var canWrite: Bool { + self.appModel.isOperatorGatewayConnected && !self.appModel + .isAppleReviewDemoModeEnabled + } + + private var currentWorkboardSubtitle: String { + Self.workboardSubtitle( + boardScopeLabel: self.boardScopeLabel, + selectedStatus: self.selectedStatus) + } + + private var boardScopeOptions: [String] { + Self.boardScopeOptions( + knownBoardIDs: self.knownBoardIDs, + cardBoardIDs: self.cards.map { self.boardID(for: $0) }) + } + + private var boardScopeLabel: String { + self.selectedBoardID.isEmpty ? "All boards" : Self.boardScopeLabel(for: self.selectedBoardID) + } + + private var selectedBoardParam: String? { + Self.normalizedScopeID(self.selectedBoardID).isEmpty ? nil : Self.normalizedScopeID(self.selectedBoardID) + } + + private var trimmedDraftTitle: String { + self.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var createUnavailableMessage: String? { + if self.isCreatingCard { + return "Card creation is already in progress." + } + if !self.canWrite { + return Self.compactWriteUnavailableMessage(canRead: self.canRead) + } + if self.trimmedDraftTitle.isEmpty { + return "Enter a title to create a card." + } + return nil + } + + private var isCompactWidth: Bool { + Self.usesCompactTaskFlow( + horizontalSizeClass: self.horizontalSizeClass, + verticalSizeClass: self.verticalSizeClass) + } + + static func usesCompactTaskFlow( + horizontalSizeClass: UserInterfaceSizeClass?, + verticalSizeClass: UserInterfaceSizeClass?) -> Bool + { + horizontalSizeClass == .compact || verticalSizeClass == .compact + } + + static func workboardSubtitle(boardScopeLabel: String, selectedStatus: String) -> String { + "\(boardScopeLabel) / \(IPadWorkboardDefaults.label(for: selectedStatus))" + } + + static func compactWriteUnavailableMessage(canRead: Bool) -> String { + canRead ? "Read-only gateway." : "Connect from Settings to create, move, and dispatch cards." + } + + static func boardScopeOptions(knownBoardIDs: [String], cardBoardIDs: [String]) -> [String] { + Array(Set((knownBoardIDs + cardBoardIDs).map { self.normalizedScopeID($0) }.filter { !$0.isEmpty })) + .sorted() + } + + private var visibleKanbanStatuses: [String] { + if self.selectedStatus == "active" { + return self.statuses.filter { $0 != "done" } + } + if self.statuses.contains(self.selectedStatus) { + return [self.selectedStatus] + } + return self.statuses + } + + private var compactStatuses: [String] { + let preferred = ["todo", "ready", "running", "review", "blocked", "scheduled", "done"] + let known = preferred.filter { self.statuses.contains($0) } + let custom = self.statuses.filter { !preferred.contains($0) } + return known + custom + } + + private func cards(forKanbanStatus status: String) -> [IPadWorkboardCard] { + self.cards + .filter { card in + card.status == status && (self.selectedStatus != "active" || card.metadata?.archivedAt == nil) + } + .filter { self.matchesQuery($0) } + .sorted { $0.position < $1.position } + } + + private var filteredCards: [IPadWorkboardCard] { + self.cards + .filter { card in + if self.selectedStatus == "active" { + return card.metadata?.archivedAt == nil && card.status != "done" + } + return card.status == self.selectedStatus + } + .filter { self.matchesQuery($0) } + .sorted { left, right in + if left.status != right.status { + return IPadWorkboardDefaults.rank(left.status) < IPadWorkboardDefaults.rank(right.status) + } + return left.position < right.position + } + } + + private func matchesQuery(_ card: IPadWorkboardCard) -> Bool { + let trimmedQuery = self.query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmedQuery.isEmpty else { return true } + return [ + card.title, + card.notes, + card.agentId, + card.sessionKey, + card.labels.joined(separator: " "), + ] + .compactMap(\.self) + .joined(separator: " ") + .lowercased() + .contains(trimmedQuery) + } + + private func loadCards(force: Bool) async { + guard self.scenePhase == .active else { return } + guard self.canRead else { + self.cards = [] + self.errorText = nil + return + } + if self.isLoading { return } + + self.isLoading = true + self.errorText = nil + defer { self.isLoading = false } + + if !self.statuses.contains(self.selectedStatus), self.selectedStatus != "active" { + self.selectedStatus = "active" + } + + do { + try await self.applyCardsResponse(self.fetchCards()) + await self.loadBoardScopes(force: force) + } catch { + if force || self.cards.isEmpty { + self.errorText = Self.message(for: error) + } + } + } + + private func beginCreateCard() { + self.draftTitle = "" + self.draftNotes = "" + self.errorText = nil + self.presentedSheet = .create + } + + private func createCard() async -> Bool { + if let createUnavailableMessage { + self.errorText = createUnavailableMessage + return false + } + + self.isCreatingCard = true + self.errorText = nil + defer { self.isCreatingCard = false } + + do { + let status = self.statuses.contains(self.selectedStatus) ? self.selectedStatus : "todo" + let data = try await request( + method: "workboard.cards.create", + params: IPadWorkboardCreateParams( + title: trimmedDraftTitle, + notes: draftNotes.trimmingCharacters(in: .whitespacesAndNewlines), + status: status, + priority: "normal", + labels: [], + agentId: "", + sessionKey: nil, + position: self.nextPosition(for: status), + boardId: self.selectedBoardParam), + timeoutSeconds: 20) + try self.replace(Self.decodeCardResponse(data)) + self.draftTitle = "" + self.draftNotes = "" + return true + } catch { + self.errorText = Self.message(for: error) + return false + } + } + + private func move(_ card: IPadWorkboardCard, to status: String) async { + guard self.canWrite, self.busyCardID == nil else { return } + self.busyCardID = card.id + self.errorText = nil + defer { self.busyCardID = nil } + + do { + let data = try await request( + method: "workboard.cards.move", + params: IPadWorkboardMoveParams( + id: card.id, + status: status, + position: self.nextPosition(for: status, excluding: card.id)), + timeoutSeconds: 20) + try self.replace(Self.decodeCardResponse(data)) + } catch { + self.errorText = Self.message(for: error) + } + } + + private func archive(_ card: IPadWorkboardCard) async { + guard self.canWrite, self.busyCardID == nil else { return } + self.busyCardID = card.id + self.errorText = nil + defer { self.busyCardID = nil } + + do { + let data = try await request( + method: "workboard.cards.archive", + params: IPadWorkboardArchiveParams( + id: card.id, + archived: card.metadata?.archivedAt == nil), + timeoutSeconds: 20) + try self.replace(Self.decodeCardResponse(data)) + } catch { + self.errorText = Self.message(for: error) + } + } + + private func dispatchCards() async { + guard self.canWrite, !self.isLoading else { return } + self.isLoading = true + self.errorText = nil + self.dispatchSummaryText = nil + defer { self.isLoading = false } + + do { + let data = try await request( + method: "workboard.cards.dispatch", + params: IPadWorkboardListParams(boardId: selectedBoardParam), + timeoutSeconds: 45) + self.dispatchSummaryText = try JSONDecoder() + .decode(IPadWorkboardDispatchSummary.self, from: data) + .summaryText + try await self.applyCardsResponse(self.fetchCards()) + } catch { + self.errorText = Self.message(for: error) + } + } + + private func open(_ card: IPadWorkboardCard) { + guard let sessionKey = normalized(card.sessionKey) else { return } + self.appModel.openChat(sessionKey: sessionKey) + self.openChat() + } + + private func replace(_ card: IPadWorkboardCard) { + self.cards.removeAll { $0.id == card.id } + self.cards.append(card) + self.cards.sort { $0.position < $1.position } + } + + private func fetchCards() async throws -> IPadWorkboardCardsResponse { + let data = try await request( + method: "workboard.cards.list", + params: IPadWorkboardListParams(boardId: selectedBoardParam), + timeoutSeconds: 20) + return try JSONDecoder().decode(IPadWorkboardCardsResponse.self, from: data) + } + + private func applyCardsResponse(_ response: IPadWorkboardCardsResponse) { + self.cards = response.cards.sorted { $0.position < $1.position } + self.statuses = self.normalizedStatuses(response.statuses) + self.rememberBoardIDs(from: response.cards) + } + + private func loadBoardScopes(force: Bool) async { + do { + let data = try await request( + method: "workboard.boards.list", + params: EmptyParams(), + timeoutSeconds: 20) + let response = try JSONDecoder().decode(IPadWorkboardBoardsResponse.self, from: data) + self.rememberBoardIDs(from: response.boards) + } catch { + if force, self.knownBoardIDs.isEmpty { + self.errorText = Self.message(for: error) + } + } + } + + private func request(method: String, params: some Encodable, timeoutSeconds: Int) async throws -> Data { + guard self.canRead else { throw IPadSidebarGatewayError.offline } + let data = try JSONEncoder().encode(params) + guard let json = String(data: data, encoding: .utf8) else { + throw IPadSidebarGatewayError.invalidPayload + } + return try await self.appModel.operatorSession.request( + method: method, + paramsJSON: json, + timeoutSeconds: timeoutSeconds) + } + + private func normalizedStatuses(_ statuses: [String]?) -> [String] { + let normalized = (statuses ?? []) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return normalized.isEmpty ? IPadWorkboardDefaults.statuses : normalized + } + + private func nextPosition(for status: String, excluding cardID: String? = nil) -> Double { + let maxPosition = self.cards + .filter { $0.status == status && $0.id != cardID } + .map(\.position) + .max() ?? 0 + return maxPosition + 1000 + } + + private static func decodeCardResponse(_ data: Data) throws -> IPadWorkboardCard { + try JSONDecoder().decode(IPadWorkboardCardResponse.self, from: data).card + } + + private func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func boardID(for card: IPadWorkboardCard) -> String { + Self.normalizedScopeID(card.metadata?.automation?.boardId).isEmpty + ? "default" + : Self.normalizedScopeID(card.metadata?.automation?.boardId) + } + + private func rememberBoardIDs(from cards: [IPadWorkboardCard]) { + let discovered = cards.map { self.boardID(for: $0) } + self.knownBoardIDs = Array(Set(self.knownBoardIDs + discovered)).sorted() + } + + private func rememberBoardIDs(from boards: [IPadWorkboardBoardSummary]) { + let discovered = boards.map(\.id) + self.knownBoardIDs = Self.boardScopeOptions( + knownBoardIDs: self.knownBoardIDs, + cardBoardIDs: discovered) + } + + static func normalizedScopeID(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func boardScopeLabel(for boardID: String) -> String { + let normalized = self.normalizedScopeID(boardID) + return normalized.isEmpty ? "All boards" : normalized + } + + private static func message(for error: Error) -> String { + if let gatewayError = error as? IPadSidebarGatewayError { + return gatewayError.message + } + return error.localizedDescription + } +} + +struct IPadWorkboardKanbanColumn: View { + let status: String + let cards: [IPadWorkboardCard] + let statuses: [String] + let busyCardID: String? + let openSession: (IPadWorkboardCard) -> Void + let inspect: (IPadWorkboardCard) -> Void + let move: (IPadWorkboardCard, String) -> Void + let archive: (IPadWorkboardCard) -> Void + + var body: some View { + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: IPadWorkboardDefaults.label(for: self.status), + value: "\(self.cards.count)", + actionTitle: nil, + action: nil) + + if self.cards.isEmpty { + ProStatusRow( + icon: "tray", + title: "No \(IPadWorkboardDefaults.label(for: self.status).lowercased()) cards", + detail: "Cards moved into this lane appear here.", + value: "empty", + color: .secondary, + actionTitle: nil, + action: nil) + } else { + ForEach(Array(self.cards.enumerated()), id: \.element.id) { index, card in + if index > 0 { + Divider().padding(.leading, 12) + } + IPadWorkboardKanbanCard( + card: card, + statuses: self.statuses, + isBusy: self.busyCardID == card.id, + openSession: { + self.openSession(card) + }, + inspect: { + self.inspect(card) + }, + move: { status in + self.move(card, status) + }, + archive: { + self.archive(card) + }) + } + } + } + } + } +} + +private struct IPadWorkboardKanbanCard: View { + let card: IPadWorkboardCard + let statuses: [String] + let isBusy: Bool + let openSession: () -> Void + let inspect: () -> Void + let move: (String) -> Void + let archive: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Button(action: self.inspect) { + VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .top, spacing: 10) { + ProIconBadge(systemName: self.icon, color: self.color) + VStack(alignment: .leading, spacing: 4) { + Text(self.card.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + Text(self.detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + + if !self.card.labels.isEmpty { + Text(self.card.labels.prefix(3).joined(separator: ", ")) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + HStack(spacing: 8) { + if self.card.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + Button(action: self.openSession) { + Image(systemName: "bubble.left.and.text.bubble.right") + } + .accessibilityLabel("Open Session") + .buttonStyle(.bordered) + .controlSize(.mini) + } + + Menu { + ForEach(self.statuses, id: \.self) { status in + Button("Move to \(IPadWorkboardDefaults.label(for: status))") { + self.move(status) + } + } + Button(self.card.metadata?.archivedAt == nil ? "Archive" : "Unarchive", action: self.archive) + } label: { + Image(systemName: self.isBusy ? "hourglass" : "ellipsis") + .frame(width: 22, height: 22) + } + .accessibilityLabel("Card Actions") + .buttonStyle(.bordered) + .controlSize(.mini) + .disabled(self.isBusy) + + Spacer(minLength: 4) + ProValuePill(value: IPadWorkboardDefaults.label(for: self.card.status), color: self.color) + } + } + .padding(12) + .contentShape(Rectangle()) + } + + private var icon: String { + switch self.card.status { + case "running": "figure.run" + case "review": "checklist" + case "blocked": "exclamationmark.triangle" + case "done": "checkmark.circle" + default: "tray" + } + } + + private var color: Color { + switch self.card.status { + case "running": OpenClawBrand.ok + case "review": OpenClawBrand.accent + case "blocked": OpenClawBrand.warn + case "done": .secondary + default: OpenClawBrand.accentHot + } + } + + private var detail: String { + if let notes = card.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { + return notes + } + if let sessionKey = card.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty { + return sessionKey + } + return self.card.agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? self.card.agentId ?? "Default agent" + : "Default agent" + } +} + +struct IPadWorkboardQueueRow: View { + let card: IPadWorkboardCard + let statuses: [String] + let isBusy: Bool + let inspect: () -> Void + let openSession: () -> Void + let move: (String) -> Void + let archive: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Button(action: self.inspect) { + HStack(alignment: .top, spacing: 12) { + ProIconBadge(systemName: self.icon, color: self.color) + VStack(alignment: .leading, spacing: 5) { + Text(self.card.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + Text(self.detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 8) + ProValuePill(value: IPadWorkboardDefaults.label(for: self.card.status), color: self.color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Menu { + self.actionMenuItems + } label: { + Image(systemName: self.isBusy ? "hourglass" : "ellipsis.circle") + .font(.system(size: 19, weight: .semibold)) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(OpenClawBrand.accent) + .disabled(self.isBusy) + .accessibilityLabel("Card Actions") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .contextMenu { + self.actionMenuItems + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button("Inspect", action: self.inspect) + .tint(OpenClawBrand.accent) + if self.card.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + Button("Open", action: self.openSession) + .tint(OpenClawBrand.ok) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if let nextStatus { + Button(IPadWorkboardDefaults.label(for: nextStatus)) { + self.move(nextStatus) + } + .tint(OpenClawBrand.accentHot) + } + Button(self.card.metadata?.archivedAt == nil ? "Archive" : "Unarchive", action: self.archive) + .tint(.secondary) + } + } + + @ViewBuilder + private var actionMenuItems: some View { + if self.card.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + Button("Open Session", action: self.openSession) + } + Button("Inspect", action: self.inspect) + ForEach(self.statuses, id: \.self) { status in + Button("Move to \(IPadWorkboardDefaults.label(for: status))") { + self.move(status) + } + } + Button(self.card.metadata?.archivedAt == nil ? "Archive" : "Unarchive", action: self.archive) + } + + private var nextStatus: String? { + guard let currentIndex = statuses.firstIndex(of: card.status) else { + return self.statuses.first + } + let nextIndex = self.statuses.index(after: currentIndex) + guard self.statuses.indices.contains(nextIndex) else { return nil } + return self.statuses[nextIndex] + } + + private var icon: String { + switch self.card.status { + case "running": "figure.run" + case "review": "checklist" + case "blocked": "exclamationmark.triangle" + case "done": "checkmark.circle" + default: "tray" + } + } + + private var color: Color { + switch self.card.status { + case "running": OpenClawBrand.ok + case "review": OpenClawBrand.accent + case "blocked": OpenClawBrand.warn + case "done": .secondary + default: OpenClawBrand.accentHot + } + } + + private var detail: String { + if let notes = card.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { + return notes + } + if let sessionKey = card.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty { + return sessionKey + } + return self.card.agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? self.card.agentId ?? "Default agent" + : "Default agent" + } +} + +private struct IPadWorkboardCardDetailSheet: View { + @Environment(\.dismiss) private var dismiss + let card: IPadWorkboardCard + let statuses: [String] + let isBusy: Bool + let canWrite: Bool + let openSession: () -> Void + let move: (String) -> Void + let archive: () -> Void + + var body: some View { + NavigationStack { + Form { + Section("Card") { + LabeledContent("Title", value: self.card.title) + LabeledContent("Status", value: IPadWorkboardDefaults.label(for: self.card.status)) + if let notes = self.card.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { + Text(notes) + } + } + + Section("Actions") { + if self.card.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + Button("Open Session", action: self.openSession) + } + Menu("Move") { + ForEach(self.statuses, id: \.self) { status in + Button(IPadWorkboardDefaults.label(for: status)) { + self.move(status) + } + } + } + .disabled(!self.canWrite || self.isBusy) + Button(self.card.metadata?.archivedAt == nil ? "Archive" : "Unarchive", action: self.archive) + .disabled(!self.canWrite || self.isBusy) + } + } + .navigationTitle("Card") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + self.dismiss() + } + } + } + } + } +} + +private enum IPadWorkboardSheet: Identifiable { + case create + case card(IPadWorkboardCard) + + var id: String { + switch self { + case .create: + "create" + case let .card(card): + "card-\(card.id)" + } + } +} + +private enum IPadWorkboardDefaults { + static let statuses = ["todo", "scheduled", "ready", "running", "review", "blocked", "done"] + + static func label(for status: String) -> String { + status + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") + } + + static func rank(_ status: String) -> Int { + self.statuses.firstIndex(of: status) ?? Int.max + } +} + +private struct IPadWorkboardCardsResponse: Decodable { + let cards: [IPadWorkboardCard] + let statuses: [String]? +} + +private struct IPadWorkboardCardResponse: Decodable { + let card: IPadWorkboardCard +} + +private struct IPadWorkboardBoardsResponse: Decodable { + let boards: [IPadWorkboardBoardSummary] +} + +private struct IPadWorkboardBoardSummary: Decodable { + let id: String +} + +struct IPadWorkboardCard: Decodable, Identifiable { + let id: String + let title: String + let notes: String? + let status: String + let priority: String? + let labels: [String] + let agentId: String? + let sessionKey: String? + let position: Double + let updatedAt: Double? + let metadata: IPadWorkboardMetadata? +} + +struct IPadWorkboardMetadata: Decodable { + let archivedAt: Double? + let automation: IPadWorkboardAutomationMetadata? +} + +struct IPadWorkboardAutomationMetadata: Decodable { + let boardId: String? +} + +private struct IPadWorkboardListParams: Encodable { + let boardId: String? +} + +private struct IPadWorkboardCreateParams: Encodable { + let title: String + let notes: String + let status: String + let priority: String + let labels: [String] + let agentId: String + let sessionKey: String? + let position: Double + let boardId: String? +} + +private struct IPadWorkboardMoveParams: Encodable { + let id: String + let status: String + let position: Double +} + +private struct IPadWorkboardArchiveParams: Encodable { + let id: String + let archived: Bool +} + +struct IPadWorkboardDispatchSummary: Decodable { + private let startedCount: Int + private let startFailureCount: Int + private let promotedCount: Int + private let blockedCount: Int + private let reclaimedCount: Int + private let orchestratedCount: Int + private let dispatchCount: Int + + private enum CodingKeys: String, CodingKey { + case started + case startFailures + case promoted + case blocked + case reclaimed + case orchestrated + case count + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.startedCount = Self.arrayCount(container, .started) + self.startFailureCount = Self.arrayCount(container, .startFailures) + self.promotedCount = Self.arrayCount(container, .promoted) + self.blockedCount = Self.arrayCount(container, .blocked) + self.reclaimedCount = Self.arrayCount(container, .reclaimed) + self.orchestratedCount = Self.arrayCount(container, .orchestrated) + self.dispatchCount = (try? container.decode(Int.self, forKey: .count)) ?? 0 + } + + var summaryText: String { + let total = max( + dispatchCount, + self.startedCount + self.promotedCount + self.reclaimedCount + self.orchestratedCount + + self.blockedCount + self.startFailureCount) + if total == 0, self.startFailureCount == 0, self.blockedCount == 0 { + return "No cards dispatched." + } + let outcomes = [ + Self.outcomeText(self.startedCount, "started"), + Self.outcomeText(self.promotedCount, "promoted"), + Self.outcomeText(self.reclaimedCount, "reclaimed"), + Self.outcomeText(self.orchestratedCount, "orchestrated"), + Self.outcomeText(self.blockedCount, "blocked"), + Self.outcomeText(self.startFailureCount, "failed"), + ].compactMap(\.self) + guard !outcomes.isEmpty else { + return "\(total) dispatched." + } + return "\(total) dispatched: \(outcomes.joined(separator: ", "))." + } + + private static func arrayCount( + _ container: KeyedDecodingContainer, + _ key: CodingKeys) -> Int + { + (try? container.decode([IPadWorkboardDispatchEntry].self, forKey: key).count) ?? 0 + } + + private static func outcomeText(_ count: Int, _ label: String) -> String? { + guard count > 0 else { return nil } + return "\(count) \(label)" + } +} + +private struct IPadWorkboardDispatchEntry: Decodable {} diff --git a/apps/ios/Sources/Design/OpenClawDocsScreen.swift b/apps/ios/Sources/Design/OpenClawDocsScreen.swift new file mode 100644 index 000000000000..70ed060f1037 --- /dev/null +++ b/apps/ios/Sources/Design/OpenClawDocsScreen.swift @@ -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) + } +} diff --git a/apps/ios/Sources/Design/OpenClawProComponents.swift b/apps/ios/Sources/Design/OpenClawProComponents.swift index 8f7415333cad..53bf9b1719ad 100644 --- a/apps/ios/Sources/Design/OpenClawProComponents.swift +++ b/apps/ios/Sources/Design/OpenClawProComponents.swift @@ -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: 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: 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) } } diff --git a/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift b/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift new file mode 100644 index 000000000000..835cbcfc418d --- /dev/null +++ b/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift @@ -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 diff --git a/apps/ios/Sources/Design/SettingsChannelsDestination.swift b/apps/ios/Sources/Design/SettingsChannelsDestination.swift new file mode 100644 index 000000000000..e481b0d1fe34 --- /dev/null +++ b/apps/ios/Sources/Design/SettingsChannelsDestination.swift @@ -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 diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index 4747dc4b9b69..632909f45534 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -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] } } diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index bdb1aa03f20d..04e6b7bd1ca0 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -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 { Binding( get: { self.manualGatewayPortText }, diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index ff5f451bb3e7..76365de8d55a 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -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 { diff --git a/apps/ios/Sources/Design/SettingsProTabSupport.swift b/apps/ios/Sources/Design/SettingsProTabSupport.swift index 9fef5827211f..256731cc8798 100644 --- a/apps/ios/Sources/Design/SettingsProTabSupport.swift +++ b/apps/ios/Sources/Design/SettingsProTabSupport.swift @@ -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 diff --git a/apps/ios/Sources/Design/TalkProTab.swift b/apps/ios/Sources/Design/TalkProTab.swift index 1f0f35e5ce91..0ed1c3fb00c6 100644 --- a/apps/ios/Sources/Design/TalkProTab.swift +++ b/apps/ios/Sources/Design/TalkProTab.swift @@ -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") diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 8c150c6317d5..aad7f52598d5 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -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 { diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 5a58778137cf..c16c26e31457 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -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? @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 diff --git a/apps/ios/Sources/RootTabsNavigation.swift b/apps/ios/Sources/RootTabsNavigation.swift new file mode 100644 index 000000000000..dbecf856bec9 --- /dev/null +++ b/apps/ios/Sources/RootTabsNavigation.swift @@ -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 } + } +} diff --git a/apps/ios/Sources/Status/GatewayActionsDialog.swift b/apps/ios/Sources/Status/GatewayActionsDialog.swift deleted file mode 100644 index 8c1ec42f3b83..000000000000 --- a/apps/ios/Sources/Status/GatewayActionsDialog.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI - -extension View { - func gatewayActionsDialog( - isPresented: Binding, - 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?") - } - } -} diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 7c7b39826fdb..5479584bb5ca 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -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 diff --git a/apps/ios/Tests/CommandCenterTabLayoutTests.swift b/apps/ios/Tests/CommandCenterTabLayoutTests.swift new file mode 100644 index 000000000000..e49161529053 --- /dev/null +++ b/apps/ios/Tests/CommandCenterTabLayoutTests.swift @@ -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)) + } +} diff --git a/apps/ios/Tests/GatewayStatusBuilderTests.swift b/apps/ios/Tests/GatewayStatusBuilderTests.swift index 8c0984e89385..334f6de48dc1 100644 --- a/apps/ios/Tests/GatewayStatusBuilderTests.swift +++ b/apps/ios/Tests/GatewayStatusBuilderTests.swift @@ -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") + } } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 43be1690d80b..3d00ef0cb7a1 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -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) { diff --git a/apps/ios/Tests/RootTabsPresentationTests.swift b/apps/ios/Tests/RootTabsPresentationTests.swift index 554ddd9f9759..53ffd5c0e6fd 100644 --- a/apps/ios/Tests/RootTabsPresentationTests.swift +++ b/apps/ios/Tests/RootTabsPresentationTests.swift @@ -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) + } } diff --git a/apps/ios/Tests/RootTabsSidebarRegressionTests.swift b/apps/ios/Tests/RootTabsSidebarRegressionTests.swift new file mode 100644 index 000000000000..fcfcbc7cc96b --- /dev/null +++ b/apps/ios/Tests/RootTabsSidebarRegressionTests.swift @@ -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[.. 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: 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: 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[.. 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 + } } diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 9d7a3fa1ab61..f5b44c3d1872 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -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 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift new file mode 100644 index 000000000000..47a8fe853ea5 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift @@ -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 { + 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) + } +} diff --git a/extensions/workboard/src/cli.ts b/extensions/workboard/src/cli.ts index f1f5c813dbe9..24155bb42dc3 100644 --- a/extensions/workboard/src/cli.ts +++ b/extensions/workboard/src/cli.ts @@ -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 ", "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 { diff --git a/extensions/workboard/src/dispatcher.test.ts b/extensions/workboard/src/dispatcher.test.ts index c137bea15a01..65002922aa82 100644 --- a/extensions/workboard/src/dispatcher.test.ts +++ b/extensions/workboard/src/dispatcher.test.ts @@ -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({ diff --git a/extensions/workboard/src/dispatcher.ts b/extensions/workboard/src/dispatcher.ts index 998e3da2cc7c..1d82eef3a4f6 100644 --- a/extensions/workboard/src/dispatcher.ts +++ b/extensions/workboard/src/dispatcher.ts @@ -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 { 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 = ""; diff --git a/extensions/workboard/src/gateway.test.ts b/extensions/workboard/src/gateway.test.ts index 980d079eb963..c5ecf29ad1c6 100644 --- a/extensions/workboard/src/gateway.test.ts +++ b/extensions/workboard/src/gateway.test.ts @@ -218,6 +218,49 @@ describe("workboard gateway methods", () => { }); }); + it("dispatches workboard cards when gateway params are omitted", async () => { + type RegisteredMethod = { + handler: Parameters[1]; + opts: Parameters[2]; + }; + const methods = new Map(); + 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[1]; diff --git a/extensions/workboard/src/gateway.ts b/extensions/workboard/src/gateway.ts index c37e620f720f..ed21d6db94cc 100644 --- a/extensions/workboard/src/gateway.ts +++ b/extensions/workboard/src/gateway.ts @@ -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, diff --git a/extensions/workboard/src/store.test.ts b/extensions/workboard/src/store.test.ts index 793e7bb87462..216e593631d9 100644 --- a/extensions/workboard/src/store.test.ts +++ b/extensions/workboard/src/store.test.ts @@ -2419,6 +2419,35 @@ describe("WorkboardStore", () => { ); }); + it("scopes dispatch mutations by board", async () => { + const boards = createMemoryStore(); + 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(), diff --git a/extensions/workboard/src/store.ts b/extensions/workboard/src/store.ts index a17a3020115a..0198919886ea 100644 --- a/extensions/workboard/src/store.ts +++ b/extensions/workboard/src/store.ts @@ -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 { + async dispatch( + input: number | WorkboardDispatchOptions = Date.now(), + ): Promise { + 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(); - 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") { diff --git a/extensions/workboard/src/tools.ts b/extensions/workboard/src/tools.ts index 1a695b4604bb..63a20e8a9398 100644 --- a/extensions/workboard/src/tools.ts +++ b/extensions/workboard/src/tools.ts @@ -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) + : {}; + const result = await store.dispatch({ boardId: record.boardId }); return jsonResult({ ...result, promoted: result.promoted.map(redactClaimToken),