import ByreAPI import SwiftUI typealias Schedule = Components.Schemas.Schedule typealias ScheduleSave = Components.Schemas.ScheduleSave typealias ScheduleEvery = Components.Schemas.ScheduleEvery typealias Weekday = Components.Schemas.Weekday private let weekdayNames: [(Weekday, String, String)] = [ (.mon, "M", "Mon"), (.tue, "T", "Tue"), (.wed, "W", "Wed"), (.thu, "T", "Thu"), (.fri, "F", "Fri"), (.sat, "S", "Sat"), (.sun, "S", "Sun"), ] private func twoDigits(_ n: Int) -> String { n < 10 ? "0\(n)" : "\(n)" } private func clock(_ hour: Int, _ minute: Int) -> String { "\(hour):\(twoDigits(minute))" } private func ordinal(_ n: Int) -> String { let suffix = (11...13).contains(n % 100) ? "th" : [1: "st", 2: "nd", 3: "rd"][n % 10] ?? "th" return "\(n)\(suffix)" } func scheduleTiming(_ sc: Schedule) -> String { let at = clock(sc.hour, sc.minute) switch sc.every { case .hour: return "Every hour at :\(twoDigits(sc.minute))" case .day: return "Daily at \(at)" case .week: let days = Set(sc.weekdays) if days.count == 7 { return "Daily at \(at)" } if days == [.mon, .tue, .wed, .thu, .fri] { return "Weekdays at \(at)" } if days == [.sat, .sun] { return "Weekends at \(at)" } let names = weekdayNames.filter { days.contains($0.0) }.map(\.2) return names.count == 1 ? "\(names[0])days at \(at)" : "\(names.joined(separator: ", ")) at \(at)" case .month: return "Monthly on the \(ordinal(sc.dayOfMonth)) at \(at)" } } private let nextRunFormat: DateFormatter = { let f = DateFormatter() f.setLocalizedDateFormatFromTemplate("EEE MMM d jmm") return f }() struct ScheduleRows: View { let project: Project @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays var body: some View { let list = store.schedules[project.id] ?? [] if list.isEmpty { Text("No schedules.") .foregroundStyle(Palette.muted) .padding(.horizontal, 10) .padding(.vertical, 6) } VStack(alignment: .leading, spacing: 0) { ForEach(list, id: \.id) { sc in HoverRow { Task { await editSchedule(project, sc, store, overlays) } } content: { Text(sc.title) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) Text(scheduleTiming(sc)) .font(Fonts.font(11)) .foregroundStyle(Palette.muted) Text("next \(nextRunFormat.string(from: sc.nextRun))") .font(Fonts.font(11)) .foregroundStyle(Palette.muted) } .help(sc.workflow.isEmpty ? "Runs one agent on \(sc.model)" : "Runs workflow \(sc.workflow)") } } .padding(.bottom, 8) } } @MainActor @Observable private final class ScheduleDraft { var title: String var every: ScheduleEvery var weekdays: Set var hour: Int var minute: Int var dayOfMonth: Int var workflow: String var model: String var prompt: String var workflows: [String] = [] var failure = "" init(_ sc: Schedule?, defaultModel: String) { title = sc?.title ?? "" every = sc?.every ?? .week weekdays = Set(sc?.weekdays ?? [.mon]) hour = sc?.hour ?? 9 minute = sc?.minute ?? 0 dayOfMonth = sc.map { $0.dayOfMonth == 0 ? 1 : $0.dayOfMonth } ?? 1 workflow = sc?.workflow ?? "" model = sc.map { $0.model.isEmpty ? defaultModel : $0.model } ?? defaultModel prompt = sc?.prompt ?? "" } var valid: Bool { !trimmed(title).isEmpty && (every != .week || !weekdays.isEmpty) && (!workflow.isEmpty || !trimmed(prompt).isEmpty) } var save: ScheduleSave { ScheduleSave( title: title, every: every, weekdays: every == .week ? weekdayNames.map(\.0).filter { weekdays.contains($0) } : nil, hour: every == .hour ? 0 : hour, minute: minute, dayOfMonth: every == .month ? dayOfMonth : nil, prompt: prompt, model: workflow.isEmpty ? model : nil, workflow: workflow.isEmpty ? nil : workflow ) } } @MainActor func editSchedule(_ project: Project, _ existing: Schedule?, _ store: Store, _ overlays: Overlays) async { let draft = ScheduleDraft(existing, defaultModel: store.defaultModel) Task { draft.workflows = (try? await store.api.client.listWorkflows().ok.body.json.map(\.name)) ?? [] } while true { let buttons = existing == nil ? [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Create", value: "save", primary: true)] : [ModalButton(label: "Delete", value: "delete", danger: true), ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Save", value: "save", primary: true)] let choice = await overlays.showModal(ScheduleEditor(draft: draft, project: project), buttons, inPane: true, canSubmit: { draft.valid }) switch choice { case "save": do { if let existing { _ = try await store.api.client.updateSchedule(path: .init(scheduleId: existing.id), body: .json(draft.save)).ok.body.json } else { _ = try await store.api.client.createSchedule(path: .init(projectId: project.id), body: .json(draft.save)).created.body.json } return } catch { draft.failure = errorText(error) } case "delete": guard let existing, await overlays.showConfirm(["Delete the schedule \"\(existing.title)\"? Tasks it already created stay."], action: "Delete", danger: true, inPane: true) else { continue } do { _ = try await store.api.client.deleteSchedule(path: .init(scheduleId: existing.id)).noContent } catch { overlays.showAlert(errorText(error)) } return default: return } } } private struct ScheduleEditor: View { @Bindable var draft: ScheduleDraft let project: Project var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Schedule a task in \(project.name), started from \(project.defaultBranch). Times are the server's.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) TextField("Task title", text: $draft.title) .modifier(FieldStyle()) HStack(alignment: .firstTextBaseline, spacing: 8) { Text("Every").foregroundStyle(Palette.muted) ChoiceMenu(value: $draft.every, options: [(.hour, "hour"), (.day, "day"), (.week, "week"), (.month, "month")]) if draft.every == .week { Text("on").foregroundStyle(Palette.muted) HStack(spacing: 4) { ForEach(weekdayNames, id: \.0) { day in dayToggle(day) } } } if draft.every == .month { Text("on the").foregroundStyle(Palette.muted) ChoiceMenu(value: $draft.dayOfMonth, options: (1...31).map { ($0, ordinal($0)) }) } Text("at").foregroundStyle(Palette.muted) if draft.every == .hour { ChoiceMenu(value: $draft.minute, options: (0...59).map { ($0, ":\(twoDigits($0))") }) } else { ChoiceMenu(value: $draft.hour, options: (0...23).map { ($0, String($0)) }) Text(":").foregroundStyle(Palette.muted) ChoiceMenu(value: $draft.minute, options: (0...59).map { ($0, twoDigits($0)) }) } } HStack(alignment: .firstTextBaseline, spacing: 8) { Text("Run").foregroundStyle(Palette.muted) let names = draft.workflow.isEmpty || draft.workflows.contains(draft.workflow) ? draft.workflows : [draft.workflow] + draft.workflows ChoiceMenu(value: $draft.workflow, options: [("", "one agent")] + names.map { ($0, "workflow \($0)") }) if draft.workflow.isEmpty { Text("on").foregroundStyle(Palette.muted) ModelPicker(model: $draft.model) } } Text(draft.workflow.isEmpty ? "The agent's first message: a complete brief, since it sees nothing else." : "The workflow's brief, given to every stage (optional).") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) PromptEditor(text: $draft.prompt) if !draft.failure.isEmpty { Text(draft.failure) .foregroundStyle(Palette.danger) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity, alignment: .leading) } private func dayToggle(_ day: (Weekday, String, String)) -> some View { let on = draft.weekdays.contains(day.0) return Button { if on { draft.weekdays.remove(day.0) } else { draft.weekdays.insert(day.0) } } label: { Text(day.1) .font(Fonts.font(11, weight: on ? 700 : 400)) .foregroundStyle(on ? Palette.text : Palette.muted) .frame(width: 20, height: 20) .background(on ? Palette.accentTint : .clear, in: RoundedRectangle(cornerRadius: 4)) .overlay(RoundedRectangle(cornerRadius: 4).stroke(on ? Palette.accent : Palette.border)) .contentShape(Rectangle()) } .buttonStyle(.plain) .help(day.2) .accessibilityLabel(day.2) } }