mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 10:09:33 +00:00
Prune unused iOS surfaces and regenerate the Xcode project. Add a scoped Periphery PR gate with hardened artifact handling and stale-status cleanup. Co-authored-by: Sash Zats <sash@zats.io>
1192 lines
45 KiB
Swift
1192 lines
45 KiB
Swift
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 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 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)
|
|
}
|
|
}
|