Add scheduled tasks with a Project tab section and lead tools

This commit is contained in:
Ian Gulliver
2026-10-21 15:15:20 -07:00
parent 4b4763779d
commit f445e1cac3
21 changed files with 1689 additions and 347 deletions
+2 -1
View File
@@ -185,7 +185,8 @@ final class Overlays {
return false
}
if let modal {
if enter && !marked {
let multiline = (NSApp.keyWindow?.firstResponder as? NSTextView).map { !$0.isFieldEditor } ?? false
if enter && !marked && !multiline {
if modal.canSubmit(), let last = modal.buttons().last {
answerModal(last.value)
}
+13
View File
@@ -171,6 +171,7 @@ private struct ProjectBlock: View {
let project: Project
let todo: Result<Todo, ApiError>?
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
let active = (store.tasks[project.id] ?? []).filter { $0.status == .active }
@@ -203,6 +204,18 @@ private struct ProjectBlock: View {
TaskRows(active: active)
heading("TODO")
TodoBlock(project: project, todo: todo, active: active)
HStack(alignment: .firstTextBaseline) {
heading("Schedules")
Spacer()
Button("New") {
Task {
await editSchedule(project, nil, store, overlays)
}
}
.buttonStyle(WebButtonStyle())
.help("Create a task in this project on a timer")
}
ScheduleRows(project: project)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
+259
View File
@@ -0,0 +1,259 @@
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)
}
}
+28
View File
@@ -58,6 +58,7 @@ final class Store {
var me: User?
var projects: [Project] = []
var tasks: [String: [ByreTask]] = [:]
var schedules: [String: [Schedule]] = [:]
var sessions: [String: [AgentSession]] = [:]
var inbox: [InboxItem] = []
private(set) var terminals: [String: [TaskTerminal]] = [:]
@@ -313,9 +314,34 @@ final class Store {
projects = try await api.client.listProjects().ok.body.json
try await each(projects.map(\.id)) { id in
try await self.loadTasks(id)
try await self.loadSchedules(id)
}
}
func loadSchedules(_ projectId: String) async throws {
let latest = claimRequest("schedules:\(projectId)")
let list = try await api.client.listSchedules(path: .init(projectId: projectId)).ok.body.json
if !latest() {
return
}
schedules[projectId] = list
}
private func applySchedule(_ ev: ByreEvent) {
guard let projectId = ev.projectId else {
return
}
var list = schedules[projectId] ?? []
if ev.kind == "schedule.saved" {
let sc = try! decodePayload(ev.payload, as: Schedule.self)
list.removeAll { $0.id == sc.id }
list.append(sc)
} else if let id = str(ev.payload.value, "id") {
list.removeAll { $0.id == id }
}
schedules[projectId] = list.sorted { ($0.nextRun, $0.id) < ($1.nextRun, $1.id) }
}
private func claimRequest(_ key: String) -> () -> Bool {
let seq = (requestSeqs[key] ?? 0) + 1
requestSeqs[key] = seq
@@ -671,6 +697,8 @@ final class Store {
spawn("load projects") {
try await self.loadProjects()
}
case "schedule.saved", "schedule.deleted":
applySchedule(ev)
case "task.created", "task.title", "task.merge_title", "task.merge_queued", "task.merge_unqueued", "task.merge_after_review", "task.merge_after_review_ended", "task.merge_after_review_cancelled", "task.parent_merge_started", "task.parent_merge_stopped", "task.merge_started", "task.merge_ended", "task.discard_started", "task.merged", "task.discarded", "task.state", "task.ready", "task.discard_suggested", "task.ready_cleared", "snapshot.created":
if let taskId = ev.taskId, ["task.merged", "task.discarded"].contains(ev.kind) || (ev.kind == "snapshot.created" && !["answer", "fork", "ask_human"].contains(str(ev.payload.value, "trigger"))) {
clearTask(self, taskId)
+20 -8
View File
@@ -295,7 +295,7 @@ private struct StepRow: View {
if let agents = node.agents {
Text("team of \(agents.count)").foregroundStyle(Palette.muted).fixedSize()
} else {
StepModelMenu(model: $node.model)
ModelPicker(model: $node.model)
}
case .order:
Text("in order").foregroundStyle(Palette.muted)
@@ -326,7 +326,7 @@ private struct StepRow: View {
TextField("Agent name", text: Binding(get: { node.agents?[i].name ?? "" }, set: { node.agents?[i].name = $0 }))
.textFieldStyle(.plain)
.font(Fonts.font(13, weight: 700))
StepModelMenu(model: Binding(get: { node.agents?[i].model ?? "" }, set: { node.agents?[i].model = $0 }))
ModelPicker(model: Binding(get: { node.agents?[i].model ?? "" }, set: { node.agents?[i].model = $0 }))
}
.padding(.top, 4)
PromptEditor(text: Binding(get: { node.agents?[i].prompt ?? "" }, set: { node.agents?[i].prompt = $0 }))
@@ -437,22 +437,34 @@ private struct StepRow: View {
}
}
private struct StepModelMenu: View {
struct ModelPicker: View {
@Binding var model: String
var body: some View {
let list = model.isEmpty || models.contains(model) ? models : [model] + models
ChoiceMenu(value: $model, options: list.map { ($0, $0) }, placeholder: "model")
}
}
struct ChoiceMenu<Value: Hashable>: View {
@Binding var value: Value
let options: [(Value, String)]
var placeholder = ""
var body: some View {
let label = options.first { $0.0 == value }?.1
Menu {
ForEach(models, id: \.self) { m in
Button(m) { model = m }
ForEach(options, id: \.0) { option in
Button(option.1) { value = option.0 }
}
} label: {
HStack(spacing: 6) {
Text(model.isEmpty ? "model" : model)
Text(label ?? placeholder)
Image(systemName: "chevron.up.chevron.down")
.font(Fonts.font(8, weight: 600))
}
.font(Fonts.font(12))
.foregroundStyle(model.isEmpty ? Palette.muted : Palette.text)
.foregroundStyle(label == nil ? Palette.muted : Palette.text)
.contentShape(Rectangle())
}
.menuStyle(.button)
@@ -462,7 +474,7 @@ private struct StepModelMenu: View {
}
}
private struct PromptEditor: View {
struct PromptEditor: View {
@Binding var text: String
@FocusState private var focused: Bool