364 lines
18 KiB
Swift
364 lines
18 KiB
Swift
import ClawdbotProtocol
|
|
import SwiftUI
|
|
|
|
struct CronJobEditor: View {
|
|
let job: CronJob?
|
|
@Binding var isSaving: Bool
|
|
@Binding var error: String?
|
|
let onCancel: () -> Void
|
|
let onSave: ([String: AnyCodable]) -> Void
|
|
|
|
let labelColumnWidth: CGFloat = 160
|
|
static let introText =
|
|
"Create a schedule that wakes clawd via the Gateway. "
|
|
+ "Use an isolated session for agent turns so your main chat stays clean."
|
|
static let sessionTargetNote =
|
|
"Main jobs post a system event into the current main session. "
|
|
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
|
|
static let scheduleKindNote =
|
|
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
|
static let isolatedPayloadNote =
|
|
"Isolated jobs always run an agent turn. The result can be delivered to a channel, "
|
|
+ "and a short summary is posted back to your main chat."
|
|
static let mainPayloadNote =
|
|
"System events are injected into the current main session. Agent turns require an isolated session target."
|
|
static let mainSummaryNote =
|
|
"Controls the label used when posting the completion summary back to the main session."
|
|
|
|
@State var name: String = ""
|
|
@State var description: String = ""
|
|
@State var agentId: String = ""
|
|
@State var enabled: Bool = true
|
|
@State var sessionTarget: CronSessionTarget = .main
|
|
@State var wakeMode: CronWakeMode = .nextHeartbeat
|
|
@State var deleteAfterRun: Bool = false
|
|
|
|
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
|
|
@State var scheduleKind: ScheduleKind = .every
|
|
@State var atDate: Date = .init().addingTimeInterval(60 * 5)
|
|
@State var everyText: String = "1h"
|
|
@State var cronExpr: String = "0 9 * * 3"
|
|
@State var cronTz: String = ""
|
|
|
|
enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { rawValue } }
|
|
@State var payloadKind: PayloadKind = .systemEvent
|
|
@State var systemEventText: String = ""
|
|
@State var agentMessage: String = ""
|
|
@State var deliver: Bool = false
|
|
@State var channel: GatewayAgentChannel = .last
|
|
@State var to: String = ""
|
|
@State var thinking: String = ""
|
|
@State var timeoutSeconds: String = ""
|
|
@State var bestEffortDeliver: Bool = false
|
|
@State var postPrefix: String = "Cron"
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(self.job == nil ? "New cron job" : "Edit cron job")
|
|
.font(.title3.weight(.semibold))
|
|
Text(Self.introText)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
ScrollView(.vertical) {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
GroupBox("Basics") {
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
GridRow {
|
|
self.gridLabel("Name")
|
|
TextField("Required (e.g. “Daily summary”)", text: self.$name)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Description")
|
|
TextField("Optional notes", text: self.$description)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Agent ID")
|
|
TextField("Optional (default agent)", text: self.$agentId)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Enabled")
|
|
Toggle("", isOn: self.$enabled)
|
|
.labelsHidden()
|
|
.toggleStyle(.switch)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Session target")
|
|
Picker("", selection: self.$sessionTarget) {
|
|
Text("main").tag(CronSessionTarget.main)
|
|
Text("isolated").tag(CronSessionTarget.isolated)
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.segmented)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Wake mode")
|
|
Picker("", selection: self.$wakeMode) {
|
|
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
|
|
Text("now").tag(CronWakeMode.now)
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.segmented)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
GridRow {
|
|
Color.clear
|
|
.frame(width: self.labelColumnWidth, height: 1)
|
|
Text(
|
|
Self.sessionTargetNote)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
|
|
GroupBox("Schedule") {
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
GridRow {
|
|
self.gridLabel("Kind")
|
|
Picker("", selection: self.$scheduleKind) {
|
|
Text("at").tag(ScheduleKind.at)
|
|
Text("every").tag(ScheduleKind.every)
|
|
Text("cron").tag(ScheduleKind.cron)
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.segmented)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
GridRow {
|
|
Color.clear
|
|
.frame(width: self.labelColumnWidth, height: 1)
|
|
Text(
|
|
Self.scheduleKindNote)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
switch self.scheduleKind {
|
|
case .at:
|
|
GridRow {
|
|
self.gridLabel("At")
|
|
DatePicker(
|
|
"",
|
|
selection: self.$atDate,
|
|
displayedComponents: [.date, .hourAndMinute])
|
|
.labelsHidden()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Auto-delete")
|
|
Toggle("Delete after successful run", isOn: self.$deleteAfterRun)
|
|
.toggleStyle(.switch)
|
|
}
|
|
case .every:
|
|
GridRow {
|
|
self.gridLabel("Every")
|
|
TextField("10m, 1h, 1d", text: self.$everyText)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
case .cron:
|
|
GridRow {
|
|
self.gridLabel("Expression")
|
|
TextField("e.g. 0 9 * * 3", text: self.$cronExpr)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Timezone")
|
|
TextField("Optional (e.g. America/Los_Angeles)", text: self.$cronTz)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
GroupBox("Payload") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
if self.sessionTarget == .isolated {
|
|
Text(Self.isolatedPayloadNote)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
self.agentTurnEditor
|
|
} else {
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
GridRow {
|
|
self.gridLabel("Kind")
|
|
Picker("", selection: self.$payloadKind) {
|
|
Text("systemEvent").tag(PayloadKind.systemEvent)
|
|
Text("agentTurn").tag(PayloadKind.agentTurn)
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.segmented)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
GridRow {
|
|
Color.clear
|
|
.frame(width: self.labelColumnWidth, height: 1)
|
|
Text(
|
|
Self.mainPayloadNote)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
switch self.payloadKind {
|
|
case .systemEvent:
|
|
TextField("System event text", text: self.$systemEventText, axis: .vertical)
|
|
.textFieldStyle(.roundedBorder)
|
|
.lineLimit(3...7)
|
|
.frame(maxWidth: .infinity)
|
|
case .agentTurn:
|
|
self.agentTurnEditor
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.sessionTarget == .isolated {
|
|
GroupBox("Main session summary") {
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
GridRow {
|
|
self.gridLabel("Prefix")
|
|
TextField("Cron", text: self.$postPrefix)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
GridRow {
|
|
Color.clear
|
|
.frame(width: self.labelColumnWidth, height: 1)
|
|
Text(
|
|
Self.mainSummaryNote)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.vertical, 2)
|
|
}
|
|
|
|
if let error, !error.isEmpty {
|
|
Text(error)
|
|
.font(.footnote)
|
|
.foregroundStyle(.red)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
HStack {
|
|
Button("Cancel") { self.onCancel() }
|
|
.keyboardShortcut(.cancelAction)
|
|
.buttonStyle(.bordered)
|
|
Spacer()
|
|
Button {
|
|
self.save()
|
|
} label: {
|
|
if self.isSaving {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Text("Save")
|
|
}
|
|
}
|
|
.keyboardShortcut(.defaultAction)
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(self.isSaving)
|
|
}
|
|
}
|
|
.padding(24)
|
|
.frame(minWidth: 720, minHeight: 640)
|
|
.onAppear { self.hydrateFromJob() }
|
|
.onChange(of: self.payloadKind) { _, newValue in
|
|
if newValue == .agentTurn, self.sessionTarget == .main {
|
|
self.sessionTarget = .isolated
|
|
}
|
|
}
|
|
.onChange(of: self.sessionTarget) { _, newValue in
|
|
if newValue == .isolated {
|
|
self.payloadKind = .agentTurn
|
|
} else if newValue == .main, self.payloadKind == .agentTurn {
|
|
self.payloadKind = .systemEvent
|
|
}
|
|
}
|
|
}
|
|
|
|
var agentTurnEditor: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
GridRow {
|
|
self.gridLabel("Message")
|
|
TextField("What should clawd do?", text: self.$agentMessage, axis: .vertical)
|
|
.textFieldStyle(.roundedBorder)
|
|
.lineLimit(3...7)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Thinking")
|
|
TextField("Optional (e.g. low)", text: self.$thinking)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Timeout")
|
|
TextField("Seconds (optional)", text: self.$timeoutSeconds)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(width: 180, alignment: .leading)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Deliver")
|
|
Toggle("Deliver result to a channel", isOn: self.$deliver)
|
|
.toggleStyle(.switch)
|
|
}
|
|
}
|
|
|
|
if self.deliver {
|
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
|
GridRow {
|
|
self.gridLabel("Channel")
|
|
Picker("", selection: self.$channel) {
|
|
Text("last").tag(GatewayAgentChannel.last)
|
|
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
|
Text("telegram").tag(GatewayAgentChannel.telegram)
|
|
Text("discord").tag(GatewayAgentChannel.discord)
|
|
Text("slack").tag(GatewayAgentChannel.slack)
|
|
Text("signal").tag(GatewayAgentChannel.signal)
|
|
Text("imessage").tag(GatewayAgentChannel.imessage)
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.segmented)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("To")
|
|
TextField("Optional override (phone number / chat id / Discord channel)", text: self.$to)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
GridRow {
|
|
self.gridLabel("Best-effort")
|
|
Toggle("Do not fail the job if delivery fails", isOn: self.$bestEffortDeliver)
|
|
.toggleStyle(.switch)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|