260 lines
10 KiB
Swift
260 lines
10 KiB
Swift
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<Weekday>
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
}
|