import ByreAPI import SwiftUI typealias WorkflowDefinition = Components.Schemas.WorkflowDefinition typealias WorkflowStep = Components.Schemas.WorkflowStep typealias TeamMember = Components.Schemas.TeamMember @MainActor @Observable final class StepNode: Identifiable { enum Kind { case stage, order, parallel } let id = UUID() var kind: Kind var name = "" var model = "" var prompt = "" var agents: [TeamMember]? var children: [StepNode] = [] weak var parent: StepNode? var open = false init(kind: Kind, children: [StepNode] = []) { self.kind = kind self.children = children for c in children { c.parent = self } } convenience init(_ step: WorkflowStep) { if let order = step.order { self.init(kind: .order, children: order.map { StepNode($0) }) } else if let parallel = step.parallel { self.init(kind: .parallel, children: parallel.map { StepNode($0) }) } else { self.init(kind: .stage) name = step.stage ?? "" model = step.model ?? "" prompt = step.prompt ?? "" agents = step.agents } } var step: WorkflowStep { switch kind { case .stage: WorkflowStep(stage: name, model: agents == nil ? model : nil, prompt: prompt, agents: agents) case .order: WorkflowStep(order: children.map(\.step)) case .parallel: WorkflowStep(parallel: children.map(\.step)) } } var index: Int { parent!.children.firstIndex { $0 === self }! } func contains(_ other: StepNode) -> Bool { other === self || children.contains { $0.contains(other) } } func stageNames() -> [String] { kind == .stage ? [name] : children.flatMap { $0.stageNames() } } func insert(_ node: StepNode, at i: Int) { node.parent = self children.insert(node, at: i) } func detach() { parent?.children.remove(at: index) parent = nil } } @MainActor private func newStage(_ root: StepNode, _ model: String) -> StepNode { let names = Set(root.stageNames()) var n = names.count + 1 while names.contains("Stage \(n)") { n += 1 } let s = StepNode(kind: .stage) s.name = "Stage \(n)" s.model = model s.open = true return s } @MainActor private func stepGraph(_ root: StepNode) -> Flowchart { var chart = Flowchart(direction: .td) func walk(_ node: StepNode, _ before: [String]) -> [String] { switch node.kind { case .order: return node.children.reduce(before) { walk($1, $0) } case .parallel: return node.children.flatMap { walk($0, before) } case .stage: let id = "n\(chart.order.count)" chart.node(id, label: node.name.isEmpty ? " " : node.name, shape: .box) for b in before { chart.edges.append(FlowEdge(from: b, to: id, label: nil, style: .solid, arrow: true)) } return [id] } } _ = walk(root, []) return chart } private struct StepLine: Identifiable { let node: StepNode let guide: String var id: UUID { node.id } } @MainActor private func stepLines(_ node: StepNode, _ lead: String) -> [StepLine] { var out: [StepLine] = [] for (i, c) in node.children.enumerated() { let last = i == node.children.count - 1 out.append(StepLine(node: c, guide: lead + (last ? "└─ " : "├─ "))) out += stepLines(c, lead + (last ? " " : "│ ")) } return out } struct WorkflowEditor: View { let original: String? let close: () -> Void @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var root: StepNode? @ViewState private var loaded: WorkflowDefinition? @ViewState private var name = "" @ViewState private var description = "" @ViewState private var failure = "" @ViewState private var saving = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 10) { Button("‹ Workflows") { Task { await leave() } } .buttonStyle(SmallLinkButtonStyle()) Spacer() Button("Save") { Task { await save() } } .buttonStyle(WebButtonStyle(primary: true)) .disabled(saving || !changed || target.isEmpty) } .padding(.top, 16) if let root { TextField("Name", text: $name) .modifier(FieldStyle()) .padding(.top, 12) TextField("Description", text: $description, axis: .vertical) .modifier(FieldStyle()) .padding(.top, 8) if !root.stageNames().isEmpty { FlowchartCanvas(layout: layoutFlowchart(stepGraph(root))) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 12) } if !failure.isEmpty { Text(failure) .foregroundStyle(Palette.danger) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) .padding(.top, 8) } VStack(alignment: .leading, spacing: 0) { ForEach(stepLines(root, "")) { row in StepRow(node: row.node, guide: row.guide, root: root) } } .padding(.top, 12) Button("Add stage") { root.insert(newStage(root, store.defaultModel), at: root.children.count) } .buttonStyle(SmallLinkButtonStyle()) .padding(.top, 8) } else if !failure.isEmpty { Text(failure).foregroundStyle(Palette.muted).padding(.top, 12) } } .padding(.horizontal, 16) .padding(.bottom, 16) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Palette.bg) .task { await load() } } private var current: WorkflowDefinition? { root.map { WorkflowDefinition(description: description.isEmpty ? nil : description, steps: $0.children.map(\.step)) } } private var target: String { name.trimmingCharacters(in: .whitespaces) } private var changed: Bool { original == nil || current != loaded || target != original } private func load() async { guard let original else { let r = StepNode(kind: .order) r.insert(newStage(r, store.defaultModel), at: 0) root = r return } do { let def = try await store.api.client.getWorkflow(path: .init(name: original)).ok.body.json name = original description = def.description ?? "" root = StepNode(kind: .order, children: def.steps.map { StepNode($0) }) loaded = current } catch { failure = errorText(error) } } private func save() async { guard let def = current else { return } saving = true defer { saving = false } do { _ = try await store.api.client.putWorkflow(path: .init(name: target), query: .init(from: original), body: .json(def)).ok.body.json close() } catch { failure = errorText(error) } } private func leave() async { if changed, current != nil, !(await overlays.showConfirm(["This workflow has unsaved changes."], action: "Discard changes", danger: true)) { return } close() } } private struct StepRow: View { @Bindable var node: StepNode let guide: String let root: StepNode @Environment(Store.self) private var store @ViewState private var target = false var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(guide).foregroundStyle(Palette.muted) switch node.kind { case .stage: Button { node.open.toggle() } label: { Image(systemName: node.open ? "chevron.down" : "chevron.right") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(Palette.muted) .frame(width: 12) } .buttonStyle(.plain) TextField("Stage name", text: $node.name) .textFieldStyle(.plain) .font(Fonts.font(13, weight: 700)) if let agents = node.agents { Text("team of \(agents.count)").foregroundStyle(Palette.muted).fixedSize() } else { StepModelMenu(model: $node.model) } case .order: Text("in order").foregroundStyle(Palette.muted) Spacer() case .parallel: Text("in parallel").foregroundStyle(Palette.muted) Spacer() } actions } .padding(.vertical, 3) .background(target ? Palette.accentTint : .clear, in: RoundedRectangle(cornerRadius: 4)) .draggable(node.id.uuidString) .dropDestination(for: String.self) { items, _ in drop(items) } isTargeted: { target = $0 } if node.kind == .stage && node.open { VStack(alignment: .leading, spacing: 6) { if node.agents != nil { Text("Every agent's prompt").foregroundStyle(Palette.muted) } PromptEditor(text: $node.prompt) if let agents = node.agents { ForEach(agents.indices, id: \.self) { i in HStack(spacing: 6) { 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 })) } .padding(.top, 4) PromptEditor(text: Binding(get: { node.agents?[i].prompt ?? "" }, set: { node.agents?[i].prompt = $0 })) } } } .padding(.leading, CGFloat(guide.count) * 8 + 18) .padding(.bottom, 8) } } } private var actions: some View { Menu { switch node.kind { case .stage: Button("Add stage after") { addAfter() } Button("Run in parallel with a new stage") { wrap(.parallel) } Button("Group with a new stage in order") { wrap(.order) } case .order, .parallel: Button("Add stage inside") { node.insert(stage(), at: node.children.count) } Button("Add stage after") { addAfter() } Button(node.kind == .order ? "Change to in parallel" : "Change to in order") { node.kind = node.kind == .order ? .parallel : .order } Button("Ungroup") { ungroup() } } Divider() let siblings = node.parent?.children ?? [] let i = siblings.firstIndex { $0 === node } Button("Move up") { move(-1) } .disabled(i == nil || i == 0) Button("Move down") { move(1) } .disabled(i == nil || i == siblings.count - 1) Divider() Button("Remove") { node.detach() } } label: { Image(systemName: "ellipsis") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Palette.muted) .frame(width: 20, height: 16) .contentShape(Rectangle()) } .menuStyle(.button) .buttonStyle(.plain) .menuIndicator(.hidden) .fixedSize() } private func stage() -> StepNode { newStage(root, store.defaultModel) } private func addAfter() { node.parent!.insert(stage(), at: node.index + 1) } private func wrap(_ kind: StepNode.Kind) { let parent = node.parent! let i = node.index node.detach() let group = StepNode(kind: kind) group.insert(node, at: 0) group.insert(stage(), at: 1) parent.insert(group, at: i) } private func ungroup() { let parent = node.parent! let i = node.index let children = node.children node.detach() for (n, c) in children.enumerated() { parent.insert(c, at: i + n) } } private func move(_ by: Int) { let parent = node.parent! let i = node.index node.detach() parent.insert(node, at: i + by) } private func drop(_ items: [String]) -> Bool { guard let id = items.first.flatMap(UUID.init(uuidString:)), let dragged = find(root, id), !dragged.contains(node) else { return false } dragged.detach() if node.kind == .stage { node.parent!.insert(dragged, at: node.index) } else { node.insert(dragged, at: node.children.count) } return true } private func find(_ n: StepNode, _ id: UUID) -> StepNode? { if n.id == id { return n } for c in n.children { if let f = find(c, id) { return f } } return nil } } private struct StepModelMenu: View { @Binding var model: String var body: some View { Menu { ForEach(models, id: \.self) { m in Button(m) { model = m } } } label: { HStack(spacing: 6) { Text(model.isEmpty ? "model" : model) Image(systemName: "chevron.up.chevron.down") .font(.system(size: 8, weight: .semibold)) } .font(Fonts.font(12)) .foregroundStyle(model.isEmpty ? Palette.muted : Palette.text) .contentShape(Rectangle()) } .menuStyle(.button) .buttonStyle(.plain) .menuIndicator(.hidden) .fixedSize() } } private struct PromptEditor: View { @Binding var text: String @FocusState private var focused: Bool var body: some View { TextEditor(text: $text) .font(Fonts.font(12)) .scrollContentBackground(.hidden) .writingToolsBehavior(.disabled) .focused($focused) .padding(.horizontal, 5) .padding(.vertical, 4) .frame(height: 160) .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) .overlay(alignment: .topLeading) { if text.isEmpty { Text("Prompt…") .foregroundStyle(Palette.muted) .padding(.horizontal, 10) .padding(.vertical, 4) .allowsHitTesting(false) } } .overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? Palette.accent : Palette.border)) } }