diff --git a/apps/macos/Sources/Clawdis/CronJobEditor+Helpers.swift b/apps/macos/Sources/Clawdis/CronJobEditor+Helpers.swift new file mode 100644 index 000000000..c8ef08668 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CronJobEditor+Helpers.swift @@ -0,0 +1,214 @@ +import Foundation +import SwiftUI + +extension CronJobEditor { + func gridLabel(_ text: String) -> some View { + Text(text) + .foregroundStyle(.secondary) + .frame(width: self.labelColumnWidth, alignment: .leading) + } + + func hydrateFromJob() { + guard let job else { return } + self.name = job.name + self.description = job.description ?? "" + self.enabled = job.enabled + self.sessionTarget = job.sessionTarget + self.wakeMode = job.wakeMode + + switch job.schedule { + case let .at(atMs): + self.scheduleKind = .at + self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000) + case let .every(everyMs, _): + self.scheduleKind = .every + self.everyText = self.formatDuration(ms: everyMs) + case let .cron(expr, tz): + self.scheduleKind = .cron + self.cronExpr = expr + self.cronTz = tz ?? "" + } + + switch job.payload { + case let .systemEvent(text): + self.payloadKind = .systemEvent + self.systemEventText = text + case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): + self.payloadKind = .agentTurn + self.agentMessage = message + self.thinking = thinking ?? "" + self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" + self.deliver = deliver ?? false + self.channel = GatewayAgentChannel(raw: channel) + self.to = to ?? "" + self.bestEffortDeliver = bestEffortDeliver ?? false + } + + self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron" + } + + func save() { + do { + self.error = nil + let payload = try self.buildPayload() + self.onSave(payload) + } catch { + self.error = error.localizedDescription + } + } + + func buildPayload() throws -> [String: AnyCodable] { + let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Name is required."]) + } + let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines) + let schedule: [String: Any] + switch self.scheduleKind { + case .at: + schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)] + case .every: + guard let ms = Self.parseDurationMs(self.everyText) else { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."]) + } + schedule = ["kind": "every", "everyMs": ms] + case .cron: + let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines) + if expr.isEmpty { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."]) + } + let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines) + if tz.isEmpty { + schedule = ["kind": "cron", "expr": expr] + } else { + schedule = ["kind": "cron", "expr": expr, "tz": tz] + } + } + + let payload: [String: Any] = { + if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() } + switch self.payloadKind { + case .systemEvent: + let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines) + return ["kind": "systemEvent", "text": text] + case .agentTurn: + return self.buildAgentTurnPayload() + } + }() + + if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [ + NSLocalizedDescriptionKey: + "Main session jobs require systemEvent payloads (switch Session target to isolated).", + ]) + } + + if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."]) + } + + if payload["kind"] as? String == "systemEvent" { + if (payload["text"] as? String ?? "").isEmpty { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "System event text is required."]) + } + } else if payload["kind"] as? String == "agentTurn" { + if (payload["message"] as? String ?? "").isEmpty { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Agent message is required."]) + } + } + + var root: [String: Any] = [ + "name": name, + "enabled": self.enabled, + "schedule": schedule, + "sessionTarget": self.sessionTarget.rawValue, + "wakeMode": self.wakeMode.rawValue, + "payload": payload, + ] + if !description.isEmpty { root["description"] = description } + + if self.sessionTarget == .isolated { + let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines) + root["isolation"] = [ + "postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed, + ] + } + + return root.mapValues { AnyCodable($0) } + } + + func buildAgentTurnPayload() -> [String: Any] { + let msg = self.agentMessage.trimmingCharacters(in: .whitespacesAndNewlines) + var payload: [String: Any] = ["kind": "agentTurn", "message": msg] + let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines) + if !thinking.isEmpty { payload["thinking"] = thinking } + if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } + payload["deliver"] = self.deliver + if self.deliver { + payload["channel"] = self.channel.rawValue + let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) + if !to.isEmpty { payload["to"] = to } + payload["bestEffortDeliver"] = self.bestEffortDeliver + } + return payload + } + + static func parseDurationMs(_ input: String) -> Int? { + let raw = input.trimmingCharacters(in: .whitespacesAndNewlines) + if raw.isEmpty { return nil } + + let rx = try? NSRegularExpression(pattern: "^(\\d+(?:\\.\\d+)?)(ms|s|m|h|d)$", options: [.caseInsensitive]) + guard let match = rx?.firstMatch(in: raw, range: NSRange(location: 0, length: raw.utf16.count)) else { + return nil + } + func group(_ idx: Int) -> String { + let range = match.range(at: idx) + guard let r = Range(range, in: raw) else { return "" } + return String(raw[r]) + } + let n = Double(group(1)) ?? 0 + if !n.isFinite || n <= 0 { return nil } + let unit = group(2).lowercased() + let factor: Double = switch unit { + case "ms": 1 + case "s": 1000 + case "m": 60000 + case "h": 3_600_000 + default: 86_400_000 + } + return Int(floor(n * factor)) + } + + func formatDuration(ms: Int) -> String { + if ms < 1000 { return "\(ms)ms" } + let s = Double(ms) / 1000.0 + if s < 60 { return "\(Int(round(s)))s" } + let m = s / 60.0 + if m < 60 { return "\(Int(round(m)))m" } + let h = m / 60.0 + if h < 48 { return "\(Int(round(h)))h" } + let d = h / 24.0 + return "\(Int(round(d)))d" + } +} diff --git a/apps/macos/Sources/Clawdis/CronJobEditor+Testing.swift b/apps/macos/Sources/Clawdis/CronJobEditor+Testing.swift new file mode 100644 index 000000000..803964bcc --- /dev/null +++ b/apps/macos/Sources/Clawdis/CronJobEditor+Testing.swift @@ -0,0 +1,28 @@ +#if DEBUG +extension CronJobEditor { + mutating func exerciseForTesting() { + self.name = "Test job" + self.description = "Test description" + self.enabled = true + self.sessionTarget = .isolated + self.wakeMode = .now + + self.scheduleKind = .every + self.everyText = "15m" + + self.payloadKind = .agentTurn + self.agentMessage = "Run diagnostic" + self.deliver = true + self.channel = .last + self.to = "+15551230000" + self.thinking = "low" + self.timeoutSeconds = "90" + self.bestEffortDeliver = true + self.postPrefix = "Cron" + + _ = self.buildAgentTurnPayload() + _ = try? self.buildPayload() + _ = self.formatDuration(ms: 45000) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/CronJobEditor.swift b/apps/macos/Sources/Clawdis/CronJobEditor.swift new file mode 100644 index 000000000..579064df8 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CronJobEditor.swift @@ -0,0 +1,345 @@ +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/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 surface, " + + "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 enabled: Bool = true + @State var sessionTarget: CronSessionTarget = .main + @State var wakeMode: CronWakeMode = .nextHeartbeat + + 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("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) + } + 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 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(GatewayAgentChannel.last) + Text("whatsapp").tag(GatewayAgentChannel.whatsapp) + Text("telegram").tag(GatewayAgentChannel.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) + } + } + } + } + } +} diff --git a/apps/macos/Sources/Clawdis/CronSettings+Actions.swift b/apps/macos/Sources/Clawdis/CronSettings+Actions.swift new file mode 100644 index 000000000..8ae63704b --- /dev/null +++ b/apps/macos/Sources/Clawdis/CronSettings+Actions.swift @@ -0,0 +1,22 @@ +import Foundation + +extension CronSettings { + func save(payload: [String: AnyCodable]) async { + guard !self.isSaving else { return } + self.isSaving = true + self.editorError = nil + do { + try await self.store.upsertJob(id: self.editingJob?.id, payload: payload) + await MainActor.run { + self.isSaving = false + self.showEditor = false + self.editingJob = nil + } + } catch { + await MainActor.run { + self.isSaving = false + self.editorError = error.localizedDescription + } + } + } +} diff --git a/apps/macos/Sources/Clawdis/CronSettings+Helpers.swift b/apps/macos/Sources/Clawdis/CronSettings+Helpers.swift new file mode 100644 index 000000000..86f313ae5 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CronSettings+Helpers.swift @@ -0,0 +1,54 @@ +import SwiftUI + +extension CronSettings { + var selectedJob: CronJob? { + guard let id = self.store.selectedJobId else { return nil } + return self.store.jobs.first(where: { $0.id == id }) + } + + func statusTint(_ status: String?) -> Color { + switch (status ?? "").lowercased() { + case "ok": .green + case "error": .red + case "skipped": .orange + default: .secondary + } + } + + func scheduleSummary(_ schedule: CronSchedule) -> String { + switch schedule { + case let .at(atMs): + let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000) + return "at \(date.formatted(date: .abbreviated, time: .standard))" + case let .every(everyMs, _): + return "every \(self.formatDuration(ms: everyMs))" + case let .cron(expr, tz): + if let tz, !tz.isEmpty { return "cron \(expr) (\(tz))" } + return "cron \(expr)" + } + } + + func formatDuration(ms: Int) -> String { + if ms < 1000 { return "\(ms)ms" } + let s = Double(ms) / 1000.0 + if s < 60 { return "\(Int(round(s)))s" } + let m = s / 60.0 + if m < 60 { return "\(Int(round(m)))m" } + let h = m / 60.0 + if h < 48 { return "\(Int(round(h)))h" } + let d = h / 24.0 + return "\(Int(round(d)))d" + } + + func nextRunLabel(_ date: Date, now: Date = .init()) -> String { + let delta = date.timeIntervalSince(now) + if delta <= 0 { return "due" } + if delta < 60 { return "in <1m" } + let minutes = Int(round(delta / 60)) + if minutes < 60 { return "in \(minutes)m" } + let hours = Int(round(Double(minutes) / 60)) + if hours < 48 { return "in \(hours)h" } + let days = Int(round(Double(hours) / 24)) + return "in \(days)d" + } +} diff --git a/apps/macos/Sources/Clawdis/CronSettings+Layout.swift b/apps/macos/Sources/Clawdis/CronSettings+Layout.swift new file mode 100644 index 000000000..1f9b38e4b --- /dev/null +++ b/apps/macos/Sources/Clawdis/CronSettings+Layout.swift @@ -0,0 +1,172 @@ +import SwiftUI + +extension CronSettings { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + self.header + self.schedulerBanner + self.content + Spacer(minLength: 0) + } + .onAppear { self.store.start() } + .onDisappear { self.store.stop() } + .sheet(isPresented: self.$showEditor) { + CronJobEditor( + job: self.editingJob, + isSaving: self.$isSaving, + error: self.$editorError, + onCancel: { + self.showEditor = false + self.editingJob = nil + }, + onSave: { payload in + Task { + await self.save(payload: payload) + } + }) + } + .alert("Delete cron job?", isPresented: Binding( + get: { self.confirmDelete != nil }, + set: { if !$0 { self.confirmDelete = nil } })) + { + Button("Cancel", role: .cancel) { self.confirmDelete = nil } + Button("Delete", role: .destructive) { + if let job = self.confirmDelete { + Task { await self.store.removeJob(id: job.id) } + } + self.confirmDelete = nil + } + } message: { + if let job = self.confirmDelete { + Text(job.displayName) + } + } + .onChange(of: self.store.selectedJobId) { _, newValue in + guard let newValue else { return } + Task { await self.store.refreshRuns(jobId: newValue) } + } + } + + var schedulerBanner: some View { + Group { + if self.store.schedulerEnabled == false { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Cron scheduler is disabled") + .font(.headline) + Spacer() + } + Text( + "Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " + + "and the Gateway restarts.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let storePath = self.store.schedulerStorePath, !storePath.isEmpty { + Text(storePath) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.orange.opacity(0.10)) + .cornerRadius(8) + } + } + } + + var header: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("Cron") + .font(.headline) + Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + HStack(spacing: 8) { + Button { + Task { await self.store.refreshJobs() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .disabled(self.store.isLoadingJobs) + + Button { + self.editorError = nil + self.editingJob = nil + self.showEditor = true + } label: { + Label("New Job", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + } + } + } + + var content: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + if let err = self.store.lastError { + Text("Error: \(err)") + .font(.footnote) + .foregroundStyle(.red) + } else if let msg = self.store.statusMessage { + Text(msg) + .font(.footnote) + .foregroundStyle(.secondary) + } + + List(selection: self.$store.selectedJobId) { + ForEach(self.store.jobs) { job in + self.jobRow(job) + .tag(job.id) + .contextMenu { self.jobContextMenu(job) } + } + } + .listStyle(.inset) + } + .frame(width: 250) + + Divider() + + self.detail + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + + @ViewBuilder + var detail: some View { + if let selected = self.selectedJob { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 12) { + self.detailHeader(selected) + self.detailCard(selected) + self.runHistoryCard(selected) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Select a job to inspect details and run history.") + .font(.callout) + .foregroundStyle(.secondary) + Text("Tip: use ‘New Job’ to add one, or enable cron in your gateway config.") + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.top, 8) + } + } +} diff --git a/apps/macos/Sources/Clawdis/CronSettings+Rows.swift b/apps/macos/Sources/Clawdis/CronSettings+Rows.swift new file mode 100644 index 000000000..b815bb234 --- /dev/null +++ b/apps/macos/Sources/Clawdis/CronSettings+Rows.swift @@ -0,0 +1,227 @@ +import SwiftUI + +extension CronSettings { + func jobRow(_ job: CronJob) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(job.displayName) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + if !job.enabled { + StatusPill(text: "disabled", tint: .secondary) + } else if let next = job.nextRunDate { + StatusPill(text: self.nextRunLabel(next), tint: .secondary) + } else { + StatusPill(text: "no next run", tint: .secondary) + } + } + HStack(spacing: 6) { + StatusPill(text: job.sessionTarget.rawValue, tint: .secondary) + StatusPill(text: job.wakeMode.rawValue, tint: .secondary) + if let status = job.state.lastStatus { + StatusPill(text: status, tint: status == "ok" ? .green : .orange) + } + } + } + .padding(.vertical, 6) + } + + @ViewBuilder + func jobContextMenu(_ job: CronJob) -> some View { + Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } } + if job.sessionTarget == .isolated { + Button("Open transcript") { + WebChatManager.shared.show(sessionKey: "cron:\(job.id)") + } + } + Divider() + Button(job.enabled ? "Disable" : "Enable") { + Task { await self.store.setJobEnabled(id: job.id, enabled: !job.enabled) } + } + Button("Edit…") { + self.editingJob = job + self.editorError = nil + self.showEditor = true + } + Divider() + Button("Delete…", role: .destructive) { + self.confirmDelete = job + } + } + + func detailHeader(_ job: CronJob) -> some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 4) { + Text(job.displayName) + .font(.title3.weight(.semibold)) + Text(job.id) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + HStack(spacing: 8) { + Toggle("Enabled", isOn: Binding( + get: { job.enabled }, + set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } })) + .toggleStyle(.switch) + .labelsHidden() + Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } } + .buttonStyle(.borderedProminent) + if job.sessionTarget == .isolated { + Button("Transcript") { + WebChatManager.shared.show(sessionKey: "cron:\(job.id)") + } + .buttonStyle(.bordered) + } + Button("Edit") { + self.editingJob = job + self.editorError = nil + self.showEditor = true + } + .buttonStyle(.bordered) + } + } + } + + func detailCard(_ job: CronJob) -> some View { + VStack(alignment: .leading, spacing: 10) { + LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) } + if let desc = job.description, !desc.isEmpty { + LabeledContent("Description") { Text(desc).font(.callout) } + } + LabeledContent("Session") { Text(job.sessionTarget.rawValue) } + LabeledContent("Wake") { Text(job.wakeMode.rawValue) } + LabeledContent("Next run") { + if let date = job.nextRunDate { + Text(date.formatted(date: .abbreviated, time: .standard)) + } else { + Text("—").foregroundStyle(.secondary) + } + } + LabeledContent("Last run") { + if let date = job.lastRunDate { + Text("\(date.formatted(date: .abbreviated, time: .standard)) · \(relativeAge(from: date))") + } else { + Text("—").foregroundStyle(.secondary) + } + } + if let status = job.state.lastStatus { + LabeledContent("Last status") { Text(status) } + } + if let err = job.state.lastError, !err.isEmpty { + Text(err) + .font(.footnote) + .foregroundStyle(.orange) + .textSelection(.enabled) + } + self.payloadSummary(job.payload) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.06)) + .cornerRadius(8) + } + + func runHistoryCard(_ job: CronJob) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Run history") + .font(.headline) + Spacer() + Button { + Task { await self.store.refreshRuns(jobId: job.id) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .disabled(self.store.isLoadingRuns) + } + + if self.store.isLoadingRuns { + ProgressView().controlSize(.small) + } + + if self.store.runEntries.isEmpty { + Text("No run log entries yet.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(self.store.runEntries) { entry in + self.runRow(entry) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.06)) + .cornerRadius(8) + } + + func runRow(_ entry: CronRunLogEntry) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + StatusPill(text: entry.status ?? "unknown", tint: self.statusTint(entry.status)) + Text(entry.date.formatted(date: .abbreviated, time: .standard)) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if let ms = entry.durationMs { + Text("\(ms)ms") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + if let summary = entry.summary, !summary.isEmpty { + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(2) + } + if let error = entry.error, !error.isEmpty { + Text(error) + .font(.caption) + .foregroundStyle(.orange) + .textSelection(.enabled) + .lineLimit(2) + } + } + .padding(.vertical, 4) + } + + func payloadSummary(_ payload: CronPayload) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("Payload") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + switch payload { + case let .systemEvent(text): + Text(text) + .font(.callout) + .textSelection(.enabled) + case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _): + VStack(alignment: .leading, spacing: 4) { + Text(message) + .font(.callout) + .textSelection(.enabled) + HStack(spacing: 8) { + if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) } + if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } + if deliver ?? false { + StatusPill(text: "deliver", tint: .secondary) + if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) } + if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) } + } + } + } + } + } + } +} diff --git a/apps/macos/Sources/Clawdis/CronSettings+Testing.swift b/apps/macos/Sources/Clawdis/CronSettings+Testing.swift new file mode 100644 index 000000000..57e2c82ee --- /dev/null +++ b/apps/macos/Sources/Clawdis/CronSettings+Testing.swift @@ -0,0 +1,117 @@ +import SwiftUI + +#if DEBUG +struct CronSettings_Previews: PreviewProvider { + static var previews: some View { + let store = CronJobsStore(isPreview: true) + store.jobs = [ + CronJob( + id: "job-1", + name: "Daily summary", + description: nil, + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: .every(everyMs: 86_400_000, anchorMs: nil), + sessionTarget: .isolated, + wakeMode: .now, + payload: .agentTurn( + message: "Summarize inbox", + thinking: "low", + timeoutSeconds: 600, + deliver: true, + channel: "last", + to: nil, + bestEffortDeliver: true), + isolation: CronIsolation(postToMainPrefix: "Cron"), + state: CronJobState( + nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), + runningAtMs: nil, + lastRunAtMs: nil, + lastStatus: nil, + lastError: nil, + lastDurationMs: nil)), + ] + store.selectedJobId = "job-1" + store.runEntries = [ + CronRunLogEntry( + ts: Int(Date().timeIntervalSince1970 * 1000), + jobId: "job-1", + action: "finished", + status: "ok", + error: nil, + summary: "All good.", + runAtMs: nil, + durationMs: 1234, + nextRunAtMs: nil), + ] + return CronSettings(store: store) + .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) + } +} + +@MainActor +extension CronSettings { + static func exerciseForTesting() { + let store = CronJobsStore(isPreview: true) + store.schedulerEnabled = false + store.schedulerStorePath = "/tmp/clawdis-cron-store.json" + + let job = CronJob( + id: "job-1", + name: "Daily summary", + description: "Summary job", + enabled: true, + createdAtMs: 1_700_000_000_000, + updatedAtMs: 1_700_000_100_000, + schedule: .cron(expr: "0 8 * * *", tz: "UTC"), + sessionTarget: .isolated, + wakeMode: .nextHeartbeat, + payload: .agentTurn( + message: "Summarize", + thinking: "low", + timeoutSeconds: 120, + deliver: true, + channel: "whatsapp", + to: "+15551234567", + bestEffortDeliver: true), + isolation: CronIsolation(postToMainPrefix: "[cron] "), + state: CronJobState( + nextRunAtMs: 1_700_000_200_000, + runningAtMs: nil, + lastRunAtMs: 1_700_000_050_000, + lastStatus: "ok", + lastError: nil, + lastDurationMs: 1200)) + + let run = CronRunLogEntry( + ts: 1_700_000_050_000, + jobId: job.id, + action: "finished", + status: "ok", + error: nil, + summary: "done", + runAtMs: 1_700_000_050_000, + durationMs: 1200, + nextRunAtMs: 1_700_000_200_000) + + store.jobs = [job] + store.selectedJobId = job.id + store.runEntries = [run] + + var view = CronSettings(store: store) + _ = view.body + _ = view.jobRow(job) + _ = view.jobContextMenu(job) + _ = view.detailHeader(job) + _ = view.detailCard(job) + _ = view.runHistoryCard(job) + _ = view.runRow(run) + _ = view.payloadSummary(job.payload) + _ = view.scheduleSummary(job.schedule) + _ = view.statusTint(job.state.lastStatus) + _ = view.nextRunLabel(Date()) + _ = view.formatDuration(ms: 1234) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/CronSettings.swift b/apps/macos/Sources/Clawdis/CronSettings.swift index a82ab95e2..d19a419b3 100644 --- a/apps/macos/Sources/Clawdis/CronSettings.swift +++ b/apps/macos/Sources/Clawdis/CronSettings.swift @@ -3,1188 +3,13 @@ import SwiftUI struct CronSettings: View { @Bindable var store: CronJobsStore - @State private var showEditor = false - @State private var editingJob: CronJob? - @State private var editorError: String? - @State private var isSaving = false - @State private var confirmDelete: CronJob? + @State var showEditor = false + @State var editingJob: CronJob? + @State var editorError: String? + @State var isSaving = false + @State var confirmDelete: CronJob? init(store: CronJobsStore = .shared) { self.store = store } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - self.header - self.schedulerBanner - self.content - Spacer(minLength: 0) - } - .onAppear { self.store.start() } - .onDisappear { self.store.stop() } - .sheet(isPresented: self.$showEditor) { - CronJobEditor( - job: self.editingJob, - isSaving: self.$isSaving, - error: self.$editorError, - onCancel: { - self.showEditor = false - self.editingJob = nil - }, - onSave: { payload in - Task { - await self.save(payload: payload) - } - }) - } - .alert("Delete cron job?", isPresented: Binding( - get: { self.confirmDelete != nil }, - set: { if !$0 { self.confirmDelete = nil } })) - { - Button("Cancel", role: .cancel) { self.confirmDelete = nil } - Button("Delete", role: .destructive) { - if let job = self.confirmDelete { - Task { await self.store.removeJob(id: job.id) } - } - self.confirmDelete = nil - } - } message: { - if let job = self.confirmDelete { - Text(job.displayName) - } - } - .onChange(of: self.store.selectedJobId) { _, newValue in - guard let newValue else { return } - Task { await self.store.refreshRuns(jobId: newValue) } - } - } - - private var schedulerBanner: some View { - Group { - if self.store.schedulerEnabled == false { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - Text("Cron scheduler is disabled") - .font(.headline) - Spacer() - } - Text( - "Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " + - "and the Gateway restarts.") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - if let storePath = self.store.schedulerStorePath, !storePath.isEmpty { - Text(storePath) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background(Color.orange.opacity(0.10)) - .cornerRadius(8) - } - } - } - - private var header: some View { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 4) { - Text("Cron") - .font(.headline) - Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.") - .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - HStack(spacing: 8) { - Button { - Task { await self.store.refreshJobs() } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .disabled(self.store.isLoadingJobs) - - Button { - self.editorError = nil - self.editingJob = nil - self.showEditor = true - } label: { - Label("New Job", systemImage: "plus") - } - .buttonStyle(.borderedProminent) - } - } - } - - private var content: some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 8) { - if let err = self.store.lastError { - Text("Error: \(err)") - .font(.footnote) - .foregroundStyle(.red) - } else if let msg = self.store.statusMessage { - Text(msg) - .font(.footnote) - .foregroundStyle(.secondary) - } - - List(selection: self.$store.selectedJobId) { - ForEach(self.store.jobs) { job in - self.jobRow(job) - .tag(job.id) - .contextMenu { self.jobContextMenu(job) } - } - } - .listStyle(.inset) - } - .frame(width: 250) - - Divider() - - self.detail - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - } - - @ViewBuilder - private var detail: some View { - if let selected = self.selectedJob { - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 12) { - self.detailHeader(selected) - self.detailCard(selected) - self.runHistoryCard(selected) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 2) - } - } else { - VStack(alignment: .leading, spacing: 8) { - Text("Select a job to inspect details and run history.") - .font(.callout) - .foregroundStyle(.secondary) - Text("Tip: use ‘New Job’ to add one, or enable cron in your gateway config.") - .font(.caption) - .foregroundStyle(.tertiary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding(.top, 8) - } - } - - private var selectedJob: CronJob? { - guard let id = self.store.selectedJobId else { return nil } - return self.store.jobs.first(where: { $0.id == id }) - } - - private func jobRow(_ job: CronJob) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(job.displayName) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - if !job.enabled { - StatusPill(text: "disabled", tint: .secondary) - } else if let next = job.nextRunDate { - StatusPill(text: self.nextRunLabel(next), tint: .secondary) - } else { - StatusPill(text: "no next run", tint: .secondary) - } - } - HStack(spacing: 6) { - StatusPill(text: job.sessionTarget.rawValue, tint: .secondary) - StatusPill(text: job.wakeMode.rawValue, tint: .secondary) - if let status = job.state.lastStatus { - StatusPill(text: status, tint: status == "ok" ? .green : .orange) - } - } - } - .padding(.vertical, 6) - } - - @ViewBuilder - private func jobContextMenu(_ job: CronJob) -> some View { - Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } } - if job.sessionTarget == .isolated { - Button("Open transcript") { - WebChatManager.shared.show(sessionKey: "cron:\(job.id)") - } - } - Divider() - Button(job.enabled ? "Disable" : "Enable") { - Task { await self.store.setJobEnabled(id: job.id, enabled: !job.enabled) } - } - Button("Edit…") { - self.editingJob = job - self.editorError = nil - self.showEditor = true - } - Divider() - Button("Delete…", role: .destructive) { - self.confirmDelete = job - } - } - - private func detailHeader(_ job: CronJob) -> some View { - HStack(alignment: .center) { - VStack(alignment: .leading, spacing: 4) { - Text(job.displayName) - .font(.title3.weight(.semibold)) - Text(job.id) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer() - HStack(spacing: 8) { - Toggle("Enabled", isOn: Binding( - get: { job.enabled }, - set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } })) - .toggleStyle(.switch) - .labelsHidden() - Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } } - .buttonStyle(.borderedProminent) - if job.sessionTarget == .isolated { - Button("Transcript") { - WebChatManager.shared.show(sessionKey: "cron:\(job.id)") - } - .buttonStyle(.bordered) - } - Button("Edit") { - self.editingJob = job - self.editorError = nil - self.showEditor = true - } - .buttonStyle(.bordered) - } - } - } - - private func detailCard(_ job: CronJob) -> some View { - VStack(alignment: .leading, spacing: 10) { - LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) } - if let desc = job.description, !desc.isEmpty { - LabeledContent("Description") { Text(desc).font(.callout) } - } - LabeledContent("Session") { Text(job.sessionTarget.rawValue) } - LabeledContent("Wake") { Text(job.wakeMode.rawValue) } - LabeledContent("Next run") { - if let date = job.nextRunDate { - Text(date.formatted(date: .abbreviated, time: .standard)) - } else { - Text("—").foregroundStyle(.secondary) - } - } - LabeledContent("Last run") { - if let date = job.lastRunDate { - Text("\(date.formatted(date: .abbreviated, time: .standard)) · \(relativeAge(from: date))") - } else { - Text("—").foregroundStyle(.secondary) - } - } - if let status = job.state.lastStatus { - LabeledContent("Last status") { Text(status) } - } - if let err = job.state.lastError, !err.isEmpty { - Text(err) - .font(.footnote) - .foregroundStyle(.orange) - .textSelection(.enabled) - } - self.payloadSummary(job.payload) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background(Color.secondary.opacity(0.06)) - .cornerRadius(8) - } - - private func runHistoryCard(_ job: CronJob) -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Run history") - .font(.headline) - Spacer() - Button { - Task { await self.store.refreshRuns(jobId: job.id) } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .disabled(self.store.isLoadingRuns) - } - - if self.store.isLoadingRuns { - ProgressView().controlSize(.small) - } - - if self.store.runEntries.isEmpty { - Text("No run log entries yet.") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 6) { - ForEach(self.store.runEntries) { entry in - self.runRow(entry) - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background(Color.secondary.opacity(0.06)) - .cornerRadius(8) - } - - private func runRow(_ entry: CronRunLogEntry) -> some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - StatusPill(text: entry.status ?? "unknown", tint: self.statusTint(entry.status)) - Text(entry.date.formatted(date: .abbreviated, time: .standard)) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - if let ms = entry.durationMs { - Text("\(ms)ms") - .font(.caption2.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - if let summary = entry.summary, !summary.isEmpty { - Text(summary) - .font(.caption) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .lineLimit(2) - } - if let error = entry.error, !error.isEmpty { - Text(error) - .font(.caption) - .foregroundStyle(.orange) - .textSelection(.enabled) - .lineLimit(2) - } - } - .padding(.vertical, 4) - } - - private func statusTint(_ status: String?) -> Color { - switch (status ?? "").lowercased() { - case "ok": .green - case "error": .red - case "skipped": .orange - default: .secondary - } - } - - private func scheduleSummary(_ schedule: CronSchedule) -> String { - switch schedule { - case let .at(atMs): - let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000) - return "at \(date.formatted(date: .abbreviated, time: .standard))" - case let .every(everyMs, _): - return "every \(self.formatDuration(ms: everyMs))" - case let .cron(expr, tz): - if let tz, !tz.isEmpty { return "cron \(expr) (\(tz))" } - return "cron \(expr)" - } - } - - private func payloadSummary(_ payload: CronPayload) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text("Payload") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - switch payload { - case let .systemEvent(text): - Text(text) - .font(.callout) - .textSelection(.enabled) - case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _): - VStack(alignment: .leading, spacing: 4) { - Text(message) - .font(.callout) - .textSelection(.enabled) - HStack(spacing: 8) { - if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) } - if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } - if deliver ?? false { - StatusPill(text: "deliver", tint: .secondary) - if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) } - if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) } - } - } - } - } - } - } - - private func formatDuration(ms: Int) -> String { - if ms < 1000 { return "\(ms)ms" } - let s = Double(ms) / 1000.0 - if s < 60 { return "\(Int(round(s)))s" } - let m = s / 60.0 - if m < 60 { return "\(Int(round(m)))m" } - let h = m / 60.0 - if h < 48 { return "\(Int(round(h)))h" } - let d = h / 24.0 - return "\(Int(round(d)))d" - } - - private func nextRunLabel(_ date: Date, now: Date = .init()) -> String { - let delta = date.timeIntervalSince(now) - if delta <= 0 { return "due" } - if delta < 60 { return "in <1m" } - let minutes = Int(round(delta / 60)) - if minutes < 60 { return "in \(minutes)m" } - let hours = Int(round(Double(minutes) / 60)) - if hours < 48 { return "in \(hours)h" } - let days = Int(round(Double(hours) / 24)) - return "in \(days)d" - } - - private func save(payload: [String: AnyCodable]) async { - guard !self.isSaving else { return } - self.isSaving = true - self.editorError = nil - do { - try await self.store.upsertJob(id: self.editingJob?.id, payload: payload) - await MainActor.run { - self.isSaving = false - self.showEditor = false - self.editingJob = nil - } - } catch { - await MainActor.run { - self.isSaving = false - self.editorError = error.localizedDescription - } - } - } } - -struct StatusPill: View { - let text: String - let tint: Color - - var body: some View { - Text(self.text) - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .foregroundStyle(self.tint == .secondary ? .secondary : self.tint) - .background((self.tint == .secondary ? Color.secondary : self.tint).opacity(0.12)) - .clipShape(Capsule()) - } -} - -struct CronJobEditor: View { - let job: CronJob? - @Binding var isSaving: Bool - @Binding var error: String? - let onCancel: () -> Void - let onSave: ([String: AnyCodable]) -> Void - - private let labelColumnWidth: CGFloat = 160 - private 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." - private 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/etc)." - private static let scheduleKindNote = - "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." - private static let isolatedPayloadNote = - "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." - private static let mainPayloadNote = - "System events are injected into the current main session. Agent turns require an isolated session target." - private static let mainSummaryNote = - "Controls the label used when posting the completion summary back to the main session." - - @State private var name: String = "" - @State private var description: String = "" - @State private var enabled: Bool = true - @State private var sessionTarget: CronSessionTarget = .main - @State private var wakeMode: CronWakeMode = .nextHeartbeat - - enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } } - @State private var scheduleKind: ScheduleKind = .every - @State private var atDate: Date = .init().addingTimeInterval(60 * 5) - @State private var everyText: String = "1h" - @State private var cronExpr: String = "0 9 * * 3" - @State private var cronTz: String = "" - - enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { rawValue } } - @State private var payloadKind: PayloadKind = .systemEvent - @State private var systemEventText: String = "" - @State private var agentMessage: String = "" - @State private var deliver: Bool = false - @State private var channel: GatewayAgentChannel = .last - @State private var to: String = "" - @State private var thinking: String = "" - @State private var timeoutSeconds: String = "" - @State private var bestEffortDeliver: Bool = false - @State private 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("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) - } - 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 - } - } - } - - private 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 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(GatewayAgentChannel.last) - Text("whatsapp").tag(GatewayAgentChannel.whatsapp) - Text("telegram").tag(GatewayAgentChannel.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) - } - } - } - } - } - - private func gridLabel(_ text: String) -> some View { - Text(text) - .foregroundStyle(.secondary) - .frame(width: self.labelColumnWidth, alignment: .leading) - } - - private func hydrateFromJob() { - guard let job else { return } - self.name = job.name - self.description = job.description ?? "" - self.enabled = job.enabled - self.sessionTarget = job.sessionTarget - self.wakeMode = job.wakeMode - - switch job.schedule { - case let .at(atMs): - self.scheduleKind = .at - self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000) - case let .every(everyMs, _): - self.scheduleKind = .every - self.everyText = self.formatDuration(ms: everyMs) - case let .cron(expr, tz): - self.scheduleKind = .cron - self.cronExpr = expr - self.cronTz = tz ?? "" - } - - switch job.payload { - case let .systemEvent(text): - self.payloadKind = .systemEvent - self.systemEventText = text - case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver): - self.payloadKind = .agentTurn - self.agentMessage = message - self.thinking = thinking ?? "" - self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" - self.deliver = deliver ?? false - self.channel = GatewayAgentChannel(raw: channel) - self.to = to ?? "" - self.bestEffortDeliver = bestEffortDeliver ?? false - } - - self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron" - } - - private func save() { - do { - self.error = nil - let payload = try self.buildPayload() - self.onSave(payload) - } catch { - self.error = error.localizedDescription - } - } - - private func buildPayload() throws -> [String: AnyCodable] { - let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines) - if name.isEmpty { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "Name is required."]) - } - let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines) - let schedule: [String: Any] - switch self.scheduleKind { - case .at: - schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)] - case .every: - guard let ms = Self.parseDurationMs(self.everyText) else { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."]) - } - schedule = ["kind": "every", "everyMs": ms] - case .cron: - let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines) - if expr.isEmpty { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."]) - } - let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines) - if tz.isEmpty { - schedule = ["kind": "cron", "expr": expr] - } else { - schedule = ["kind": "cron", "expr": expr, "tz": tz] - } - } - - let payload: [String: Any] = { - if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() } - switch self.payloadKind { - case .systemEvent: - let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines) - return ["kind": "systemEvent", "text": text] - case .agentTurn: - return self.buildAgentTurnPayload() - } - }() - - if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [ - NSLocalizedDescriptionKey: - "Main session jobs require systemEvent payloads (switch Session target to isolated).", - ]) - } - - if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."]) - } - - if payload["kind"] as? String == "systemEvent" { - if (payload["text"] as? String ?? "").isEmpty { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "System event text is required."]) - } - } else if payload["kind"] as? String == "agentTurn" { - if (payload["message"] as? String ?? "").isEmpty { - throw NSError( - domain: "Cron", - code: 0, - userInfo: [NSLocalizedDescriptionKey: "Agent message is required."]) - } - } - - var root: [String: Any] = [ - "name": name, - "enabled": self.enabled, - "schedule": schedule, - "sessionTarget": self.sessionTarget.rawValue, - "wakeMode": self.wakeMode.rawValue, - "payload": payload, - ] - if !description.isEmpty { root["description"] = description } - - if self.sessionTarget == .isolated { - let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines) - root["isolation"] = [ - "postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed, - ] - } - - return root.mapValues { AnyCodable($0) } - } - - private func buildAgentTurnPayload() -> [String: Any] { - let msg = self.agentMessage.trimmingCharacters(in: .whitespacesAndNewlines) - var payload: [String: Any] = ["kind": "agentTurn", "message": msg] - let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines) - if !thinking.isEmpty { payload["thinking"] = thinking } - if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } - payload["deliver"] = self.deliver - if self.deliver { - payload["channel"] = self.channel.rawValue - let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) - if !to.isEmpty { payload["to"] = to } - payload["bestEffortDeliver"] = self.bestEffortDeliver - } - return payload - } - - private static func parseDurationMs(_ input: String) -> Int? { - let raw = input.trimmingCharacters(in: .whitespacesAndNewlines) - if raw.isEmpty { return nil } - - let rx = try? NSRegularExpression(pattern: #"^(\d+(?:\.\d+)?)(ms|s|m|h|d)$"#, options: [.caseInsensitive]) - guard let match = rx?.firstMatch(in: raw, range: NSRange(location: 0, length: raw.utf16.count)) else { - return nil - } - func group(_ idx: Int) -> String { - let range = match.range(at: idx) - guard let r = Range(range, in: raw) else { return "" } - return String(raw[r]) - } - let n = Double(group(1)) ?? 0 - if !n.isFinite || n <= 0 { return nil } - let unit = group(2).lowercased() - let factor: Double = switch unit { - case "ms": 1 - case "s": 1000 - case "m": 60000 - case "h": 3_600_000 - default: 86_400_000 - } - return Int(floor(n * factor)) - } - - private func formatDuration(ms: Int) -> String { - if ms < 1000 { return "\(ms)ms" } - let s = Double(ms) / 1000.0 - if s < 60 { return "\(Int(round(s)))s" } - let m = s / 60.0 - if m < 60 { return "\(Int(round(m)))m" } - let h = m / 60.0 - if h < 48 { return "\(Int(round(h)))h" } - let d = h / 24.0 - return "\(Int(round(d)))d" - } -} - -#if DEBUG -struct CronSettings_Previews: PreviewProvider { - static var previews: some View { - let store = CronJobsStore(isPreview: true) - store.jobs = [ - CronJob( - id: "job-1", - name: "Daily summary", - description: nil, - enabled: true, - createdAtMs: 0, - updatedAtMs: 0, - schedule: .every(everyMs: 86_400_000, anchorMs: nil), - sessionTarget: .isolated, - wakeMode: .now, - payload: .agentTurn( - message: "Summarize inbox", - thinking: "low", - timeoutSeconds: 600, - deliver: true, - channel: "last", - to: nil, - bestEffortDeliver: true), - isolation: CronIsolation(postToMainPrefix: "Cron"), - state: CronJobState( - nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), - runningAtMs: nil, - lastRunAtMs: nil, - lastStatus: nil, - lastError: nil, - lastDurationMs: nil)), - ] - store.selectedJobId = "job-1" - store.runEntries = [ - CronRunLogEntry( - ts: Int(Date().timeIntervalSince1970 * 1000), - jobId: "job-1", - action: "finished", - status: "ok", - error: nil, - summary: "All good.", - runAtMs: nil, - durationMs: 1234, - nextRunAtMs: nil), - ] - return CronSettings(store: store) - .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) - } -} - -@MainActor -extension CronSettings { - static func exerciseForTesting() { - let store = CronJobsStore(isPreview: true) - store.schedulerEnabled = false - store.schedulerStorePath = "/tmp/clawdis-cron-store.json" - - let job = CronJob( - id: "job-1", - name: "Daily summary", - description: "Summary job", - enabled: true, - createdAtMs: 1_700_000_000_000, - updatedAtMs: 1_700_000_100_000, - schedule: .cron(expr: "0 8 * * *", tz: "UTC"), - sessionTarget: .isolated, - wakeMode: .nextHeartbeat, - payload: .agentTurn( - message: "Summarize", - thinking: "low", - timeoutSeconds: 120, - deliver: true, - channel: "whatsapp", - to: "+15551234567", - bestEffortDeliver: true), - isolation: CronIsolation(postToMainPrefix: "[cron] "), - state: CronJobState( - nextRunAtMs: 1_700_000_200_000, - runningAtMs: nil, - lastRunAtMs: 1_700_000_050_000, - lastStatus: "ok", - lastError: nil, - lastDurationMs: 1200)) - - let run = CronRunLogEntry( - ts: 1_700_000_050_000, - jobId: job.id, - action: "finished", - status: "ok", - error: nil, - summary: "done", - runAtMs: 1_700_000_050_000, - durationMs: 1200, - nextRunAtMs: 1_700_000_200_000) - - store.jobs = [job] - store.selectedJobId = job.id - store.runEntries = [run] - - var view = CronSettings(store: store) - _ = view.body - _ = view.jobRow(job) - _ = view.jobContextMenu(job) - _ = view.detailHeader(job) - _ = view.detailCard(job) - _ = view.runHistoryCard(job) - _ = view.runRow(run) - _ = view.payloadSummary(job.payload) - _ = view.scheduleSummary(job.schedule) - _ = view.statusTint(job.state.lastStatus) - _ = view.nextRunLabel(Date()) - _ = view.formatDuration(ms: 1234) - } -} - -extension CronJobEditor { - mutating func exerciseForTesting() { - self.name = "Test job" - self.description = "Test description" - self.enabled = true - self.sessionTarget = .isolated - self.wakeMode = .now - - self.scheduleKind = .every - self.everyText = "15m" - - self.payloadKind = .agentTurn - self.agentMessage = "Run diagnostic" - self.deliver = true - self.channel = .last - self.to = "+15551230000" - self.thinking = "low" - self.timeoutSeconds = "90" - self.bestEffortDeliver = true - self.postPrefix = "Cron" - - _ = self.buildAgentTurnPayload() - _ = try? self.buildPayload() - _ = self.formatDuration(ms: 45000) - } -} -#endif diff --git a/apps/macos/Sources/Clawdis/StatusPill.swift b/apps/macos/Sources/Clawdis/StatusPill.swift new file mode 100644 index 000000000..846ddd419 --- /dev/null +++ b/apps/macos/Sources/Clawdis/StatusPill.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct StatusPill: View { + let text: String + let tint: Color + + var body: some View { + Text(self.text) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .foregroundStyle(self.tint == .secondary ? .secondary : self.tint) + .background((self.tint == .secondary ? Color.secondary : self.tint).opacity(0.12)) + .clipShape(Capsule()) + } +}