fix(mac): polish config + cron layouts
This commit is contained in:
@@ -494,6 +494,8 @@ private struct CronJobEditor: View {
|
|||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
let onSave: ([String: Any]) -> Void
|
let onSave: ([String: Any]) -> Void
|
||||||
|
|
||||||
|
private let labelColumnWidth: CGFloat = 160
|
||||||
|
|
||||||
@State private var name: String = ""
|
@State private var name: String = ""
|
||||||
@State private var enabled: Bool = true
|
@State private var enabled: Bool = true
|
||||||
@State private var sessionTarget: CronSessionTarget = .main
|
@State private var sessionTarget: CronSessionTarget = .main
|
||||||
@@ -519,91 +521,195 @@ private struct CronJobEditor: View {
|
|||||||
@State private var postPrefix: String = "Cron"
|
@State private var postPrefix: String = "Cron"
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(self.job == nil ? "New cron job" : "Edit cron job")
|
Text(self.job == nil ? "New cron job" : "Edit cron job")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Spacer()
|
Text("Create a schedule that wakes clawd via the Gateway. Use an isolated session for agent turns so your main chat stays clean.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
Form {
|
ScrollView(.vertical) {
|
||||||
Section("Basics") {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
TextField("Name (optional)", text: self.$name)
|
GroupBox("Basics") {
|
||||||
Toggle("Enabled", isOn: self.$enabled)
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
Picker("Session target", selection: self.$sessionTarget) {
|
GridRow {
|
||||||
Text("main").tag(CronSessionTarget.main)
|
self.gridLabel("Name")
|
||||||
Text("isolated").tag(CronSessionTarget.isolated)
|
TextField("Optional label (e.g. “Daily summary”)", text: self.$name)
|
||||||
|
.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("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/etc).")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Picker("Wake mode", selection: self.$wakeMode) {
|
|
||||||
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
|
|
||||||
Text("now").tag(CronWakeMode.now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Schedule") {
|
GroupBox("Schedule") {
|
||||||
Picker("Kind", selection: self.$scheduleKind) {
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
Text("at").tag(ScheduleKind.at)
|
GridRow {
|
||||||
Text("every").tag(ScheduleKind.every)
|
self.gridLabel("Kind")
|
||||||
Text("cron").tag(ScheduleKind.cron)
|
Picker("", selection: self.$scheduleKind) {
|
||||||
}
|
Text("at").tag(ScheduleKind.at)
|
||||||
.pickerStyle(.segmented)
|
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("“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
switch self.scheduleKind {
|
switch self.scheduleKind {
|
||||||
case .at:
|
case .at:
|
||||||
DatePicker("At", selection: self.$atDate, displayedComponents: [.date, .hourAndMinute])
|
GridRow {
|
||||||
case .every:
|
self.gridLabel("At")
|
||||||
TextField("Every (e.g. 10m, 1h, 1d)", text: self.$everyText)
|
DatePicker("", selection: self.$atDate, displayedComponents: [.date, .hourAndMinute])
|
||||||
.textFieldStyle(.roundedBorder)
|
.labelsHidden()
|
||||||
case .cron:
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
TextField("Cron expr (5-field)", text: self.$cronExpr)
|
}
|
||||||
.textFieldStyle(.roundedBorder)
|
case .every:
|
||||||
TextField("Timezone (optional)", text: self.$cronTz)
|
GridRow {
|
||||||
.textFieldStyle(.roundedBorder)
|
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("Isolated jobs always run an agent turn. The result can be delivered to a surface, and a short summary is posted back to your main chat.")
|
||||||
|
.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("System events are injected into the current main session. Agent turns require an isolated session target.")
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Section("Payload") {
|
|
||||||
if self.sessionTarget == .isolated {
|
if self.sessionTarget == .isolated {
|
||||||
Text("Isolated jobs always run an agent turn.")
|
GroupBox("Main session summary") {
|
||||||
.font(.caption)
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
.foregroundStyle(.secondary)
|
GridRow {
|
||||||
self.agentTurnEditor
|
self.gridLabel("Prefix")
|
||||||
} else {
|
TextField("Cron", text: self.$postPrefix)
|
||||||
Picker("Kind", selection: self.$payloadKind) {
|
.textFieldStyle(.roundedBorder)
|
||||||
Text("systemEvent").tag(PayloadKind.systemEvent)
|
.frame(maxWidth: .infinity)
|
||||||
Text("agentTurn").tag(PayloadKind.agentTurn)
|
}
|
||||||
}
|
GridRow {
|
||||||
.pickerStyle(.segmented)
|
Color.clear
|
||||||
|
.frame(width: self.labelColumnWidth, height: 1)
|
||||||
switch self.payloadKind {
|
Text("Controls the label used when posting the completion summary back to the main session.")
|
||||||
case .systemEvent:
|
.font(.footnote)
|
||||||
TextField("System event text", text: self.$systemEventText, axis: .vertical)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(3...6)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
case .agentTurn:
|
}
|
||||||
self.agentTurnEditor
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
if self.sessionTarget == .isolated {
|
.padding(.vertical, 2)
|
||||||
Section("Main session summary") {
|
|
||||||
Text("Isolated jobs always post a summary back into the main session when they finish.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
TextField("Prefix", text: self.$postPrefix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(minWidth: 560, minHeight: 520)
|
|
||||||
|
|
||||||
if let error, !error.isEmpty {
|
if let error, !error.isEmpty {
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("Cancel") { self.onCancel() }
|
Button("Cancel") { self.onCancel() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
@@ -615,11 +721,13 @@ private struct CronJobEditor: View {
|
|||||||
Text("Save")
|
Text("Save")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(self.isSaving)
|
.disabled(self.isSaving)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(18)
|
.padding(24)
|
||||||
|
.frame(minWidth: 720, minHeight: 640)
|
||||||
.onAppear { self.hydrateFromJob() }
|
.onAppear { self.hydrateFromJob() }
|
||||||
.onChange(of: self.payloadKind) { _, newValue in
|
.onChange(of: self.payloadKind) { _, newValue in
|
||||||
if newValue == .agentTurn, self.sessionTarget == .main {
|
if newValue == .agentTurn, self.sessionTarget == .main {
|
||||||
@@ -636,25 +744,69 @@ private struct CronJobEditor: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var agentTurnEditor: some View {
|
private var agentTurnEditor: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
TextField("Agent message", text: self.$agentMessage, axis: .vertical)
|
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||||
.lineLimit(3...6)
|
GridRow {
|
||||||
TextField("Thinking (optional)", text: self.$thinking)
|
self.gridLabel("Message")
|
||||||
TextField("Timeout seconds (optional)", text: self.$timeoutSeconds)
|
TextField("What should clawd do?", text: self.$agentMessage, axis: .vertical)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
Toggle("Deliver result", isOn: self.$deliver)
|
.lineLimit(3...7)
|
||||||
if self.deliver {
|
.frame(maxWidth: .infinity)
|
||||||
Picker("Channel", selection: self.$channel) {
|
}
|
||||||
Text("last").tag("last")
|
GridRow {
|
||||||
Text("whatsapp").tag("whatsapp")
|
self.gridLabel("Thinking")
|
||||||
Text("telegram").tag("telegram")
|
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 surface", 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("last")
|
||||||
|
Text("whatsapp").tag("whatsapp")
|
||||||
|
Text("telegram").tag("telegram")
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("To")
|
||||||
|
TextField("Optional override (phone number / chat id)", 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TextField("To (optional)", text: self.$to)
|
|
||||||
Toggle("Best-effort deliver", isOn: self.$bestEffortDeliver)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func gridLabel(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
private func hydrateFromJob() {
|
private func hydrateFromJob() {
|
||||||
guard let job else { return }
|
guard let job else { return }
|
||||||
self.name = job.name ?? ""
|
self.name = job.name ?? ""
|
||||||
|
|||||||
Reference in New Issue
Block a user