Add review workflows, tree-shaped workflows and a workflow editor

This commit is contained in:
Ian Gulliver
2026-10-20 18:52:04 -07:00
parent 9869fad988
commit f2188c53eb
31 changed files with 2179 additions and 863 deletions
+18 -58
View File
@@ -146,7 +146,7 @@ final class ChatModel {
}
func working(_ row: Row) -> Bool {
row.pending && (running || subagents && row.inActive) || row.holdsActive && (running || subagents)
row.pending && (running || subagents && row.inActive || row.inReview) || row.holdsActive && (running || subagents)
}
var hasOlder: Bool {
@@ -286,12 +286,6 @@ final class ChatModel {
}
}
func showReview(_ n: Int, _ task: String?) {
if let row = reviewRow(n, task) {
scrollTo(row.id)
}
}
func showFinding(_ reviewId: String, _ index: Int) {
let row = builder.root.rows.first { r in
if case .finding(let i) = r.kind {
@@ -308,7 +302,7 @@ final class ChatModel {
guard let taskId else {
return []
}
let latest = latestReview(store.transcript)
let latest = latestReviewedSnapshot(store.transcript)
let task = store.findTask(taskId)
let active = task.map { $0.status == .active && !$0.merging && !$0.mergingIntoParent && $0.mergeQueuedAt == nil && !$0.mergeAfterReview } ?? false
var questions: [Int] = []
@@ -320,7 +314,7 @@ final class ChatModel {
case .question where row.question?.state == .open && row.taskId == taskId:
questions.append(row.id)
case .finding(let i):
if let r = row.review, r.id == latest?.id, findingOpen(r, i) {
if let r = row.review, r.snapshotN == latest, findingOpen(r, i) {
open.append(row.id)
}
case .ready(_, _, let d, _) where row.id == builder.lastReady && active:
@@ -1241,6 +1235,20 @@ struct RowView: View {
}
}
case .reviewer(let agentModel):
DetailsBox(nested: nested, working: row.active, status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
Text(styled([(row.review?.stage ?? "review", 600, nil), (agentModel.isEmpty ? "" : " · \(agentModel)", 400, Palette.muted), (row.text.isEmpty ? "" : " \(row.text)", 400, Palette.muted)]))
.lineLimit(1)
} content: {
VStack(alignment: .leading, spacing: 6) {
ForEach(row.body!.rows) { child in
RowView(row: child, model: model, nested: true)
}
}
.padding(.top, 6)
.padding(.bottom, 4)
}
case .group:
DetailsBox(nested: nested, working: model.working(row)) {
SubagentTag(show: row.tag)
@@ -1318,9 +1326,7 @@ struct RowView: View {
}
case .snapshot(let snap):
SnapshotRow(snap: snap, review: row.review, taskId: row.taskId, inherited: row.taskId != model.taskId, forks: row.forks) {
model.showReview(snap.n, row.taskId)
}
SnapshotRow(snap: snap, taskId: row.taskId, inherited: row.taskId != model.taskId, forks: row.forks)
case .question:
if let item = row.question {
@@ -1727,14 +1733,11 @@ func taskLink(_ store: Store, _ id: String) -> AttributedString {
private struct SnapshotRow: View {
let snap: Snapshot
let review: Review?
let taskId: String?
let inherited: Bool
let forks: [String]
let showReview: () -> Void
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@Environment(\.paneAccent) private var accent
@ViewState private var forking = false
var body: some View {
@@ -1768,9 +1771,6 @@ private struct SnapshotRow: View {
.buttonStyle(SmallLinkButtonStyle())
.disabled(forking)
.help("Start a new task from this snapshot, its agent continuing the conversation as it stood here")
if ![.answer, .fork, .askHuman].contains(snap.trigger) {
reviewButton
}
ForEach(forks, id: \.self) { id in
Text(AttributedString("forked as ") + taskLink(store, id))
.fixedSize()
@@ -1782,44 +1782,4 @@ private struct SnapshotRow: View {
.help("\(snap.trigger.rawValue.replacingOccurrences(of: "_", with: " ")) · \(time)")
}
private var reviewButton: some View {
let n = review.map(findingCount) ?? 0
return Button {
showReview()
} label: {
Text("review")
.font(Fonts.font(11, weight: n > 0 ? 700 : 400))
.overlay(alignment: .topTrailing) {
if let review {
switch review.state {
case .running, .failed:
EmptyView()
case .skipped:
Text("●").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.muted).help("skipped: no changes")
.fixedSize()
.offset(x: 13, y: -7)
case .done:
if n == 0 {
Text("✓").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.ok).help(review.findingStates.additionalProperties.isEmpty ? "no findings" : "every finding addressed")
.fixedSize()
.offset(x: 13, y: -7)
} else {
Text("\(n)")
.font(Fonts.font(10, weight: 700))
.foregroundStyle(Palette.danger)
.frame(height: 14)
.padding(.horizontal, 5)
.background(Palette.dangerTint, in: Capsule())
.help("\(n) open findings")
.fixedSize()
.offset(x: 15, y: -9)
}
}
}
}
}
.buttonStyle(SmallLinkButtonStyle(color: n > 0 ? Palette.warn : Palette.accent, border: review?.state == .running ? accent : Palette.border))
.disabled(review == nil || review?.state == .running)
.help(review?.state == .running ? "Review in progress" : review == nil ? "Not reviewed" : "Scroll to the review of snapshot \(snap.n)")
}
}
+8 -7
View File
@@ -177,27 +177,28 @@ private func reviewWarnings(_ taskId: String, _ store: Store, _ leave: @escaping
store.jumpTo(taskId, .review(latest.n))
}
}
let r = reviews.first { $0.snapshotN == latest.n }
if let r {
let rs = reviews.filter { $0.snapshotN == latest.n }
for r in rs {
let found = unignored(r)
switch r.state {
case .running:
items.append(MergeWarning(text: "Review of the latest snapshot is still running", open: open))
items.append(MergeWarning(text: "The \(r.stage) review of the latest snapshot is still running", open: open))
case .failed:
items.append(MergeWarning(text: "Review of the latest snapshot failed", open: open))
items.append(MergeWarning(text: "The \(r.stage) review of the latest snapshot failed", open: open))
default:
if !found.isEmpty {
items.append(MergeWarning(text: "Review of the latest snapshot found \(severityCounts(found))", open: open))
items.append(MergeWarning(text: "The \(r.stage) review of the latest snapshot found \(severityCounts(found))", open: open))
}
}
} else {
}
if rs.isEmpty {
items.append(MergeWarning(text: "The latest snapshot hasn't been reviewed", open: open))
}
let changes = try await client.getTaskChanges(path: .init(taskId: taskId), query: .init(since: latest.commitSha)).ok.body.json
if !changes.files.isEmpty {
items.append(MergeWarning(text: "Changes since the last snapshot haven't been reviewed"))
}
return (items, r?.state == .running)
return (items, rs.contains { $0.state == .running })
}
private func unignored(_ r: Review) -> [JSONDict] {
+49
View File
@@ -110,6 +110,53 @@ struct ProjectView: View {
}
}
private struct ReviewWorkflowMenu: View {
let project: Project
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var reviewWorkflows: [String] = []
var body: some View {
Menu {
Button("No reviews") { set("") }
ForEach(reviewWorkflows, id: \.self) { name in
Button(name) { set(name) }
}
} label: {
HStack(spacing: 6) {
Text(project.reviewWorkflow.isEmpty ? "no reviews" : "reviews: \(project.reviewWorkflow)")
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8, weight: .semibold))
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.contentShape(Rectangle())
}
.menuStyle(.button)
.buttonStyle(.plain)
.menuIndicator(.hidden)
.fixedSize()
.help("The review workflow whose reviewers review every snapshot of this project's tasks")
.task {
do {
reviewWorkflows = try await store.api.client.listWorkflows().ok.body.json.filter { $0.kind == .review }.map(\.name)
} catch {
reviewWorkflows = []
}
}
}
private func set(_ name: String) {
Task {
do {
_ = try await store.api.client.setReviewWorkflow(path: .init(projectId: project.id), body: .json(.init(workflow: name))).ok.body.json
} catch {
overlays.showAlert(errorText(error))
}
}
}
}
private struct ProjectBlock: View {
let project: Project
let todo: Result<Todo, ApiError>?
@@ -121,6 +168,8 @@ private struct ProjectBlock: View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(project.name).font(Fonts.font(13, weight: 700))
Text(project.defaultBranch).font(Fonts.font(11)).foregroundStyle(Palette.muted)
Spacer()
ReviewWorkflowMenu(project: project)
}
.padding(.top, 16)
.padding(.bottom, 6)
+1 -1
View File
@@ -274,7 +274,7 @@ struct FindingActions: View {
private func fix() {
let f = findings(review)[index]
let whereText = findingWhere(f)
let text = "Fix finding \(index + 1) from the review of snapshot #\(review.snapshotN):\n\n**[\(jsString(f["severity"] ?? ""))] \(jsString(f["title"] ?? ""))**\(whereText.isEmpty ? "" : " (`\(whereText)`)")\n\n\(jsString(f["detail"] ?? ""))"
let text = "Fix finding \(index + 1) from reviewer \"\(review.stage)\" of snapshot #\(review.snapshotN):\n\n**[\(jsString(f["severity"] ?? ""))] \(jsString(f["title"] ?? ""))**\(whereText.isEmpty ? "" : " (`\(whereText)`)")\n\n\(jsString(f["detail"] ?? ""))"
busy = true
Task {
do {
-12
View File
@@ -37,18 +37,6 @@ struct SettingsView: View {
ModelMenu(current: store.defaultModel, help: "Model for new tasks") { m in
try await store.api.client.updateDefaultModel(body: .json(.init(model: m))).ok.body.json
}
if let me = store.me {
heading("Review models")
Text("The first model reviews your tasks' work. The second reviews work the first model wrote.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
ModelMenu(current: me.reviewModel, help: "Model that reviews your tasks") { m in
try await store.api.client.updateReviewModels(body: .json(.init(reviewModel: m, secondReviewModel: me.secondReviewModel))).ok.body.json
}
ModelMenu(current: me.secondReviewModel, help: "Model that reviews work the first review model wrote") { m in
try await store.api.client.updateReviewModels(body: .json(.init(reviewModel: me.reviewModel, secondReviewModel: m))).ok.body.json
}
}
heading("iCloud sync")
SyncState()
heading("This Mac's permissions")
+1 -1
View File
@@ -635,7 +635,7 @@ final class Store {
try await self.loadMe()
}
}
case "project.created":
case "project.created", "project.updated":
spawn("load projects") {
try await self.loadProjects()
}
+1 -1
View File
@@ -159,7 +159,7 @@ final class SwitcherModel {
self.anchor = anchor
Task {
do {
workflows = try await store.api.client.listWorkflows().ok.body.json
workflows = try await store.api.client.listWorkflows().ok.body.json.filter { $0.kind == .task }
} catch {
overlays.showAlert(errorText(error))
}
+123 -35
View File
@@ -46,6 +46,7 @@ enum RowKind {
case thinking(md: [MDBlock])
case tool(name: String, brief: String, input: String)
case subagent(title: String, description: String, prompt: [MDBlock])
case reviewer(model: String)
case group
case orphan(String)
case turnEnd(error: String?)
@@ -123,6 +124,17 @@ final class Row: Identifiable {
return false
}
var inReview: Bool {
var o = owner
while let r = o {
if case .reviewer = r.kind {
return r.active
}
o = r.owner
}
return false
}
func within(_ ancestor: Row) -> Bool {
var o = owner
while let r = o {
@@ -193,7 +205,7 @@ private final class Subagent {
private final class Ctx {
var tools: [String: Row] = [:]
var subagents: [String: Subagent] = [:]
var reviewers: Set<String> = []
var reviewers: [String: Row] = [:]
var snapshots: [String: Row] = [:]
var reviews: [String: Review] = [:]
var reviewRows: [String: [Row]] = [:]
@@ -378,7 +390,7 @@ final class TranscriptBuilder {
if ctx.handoff != nil {
return
}
if let sid = ev.agentSessionId, ctx.reviewers.contains(sid) {
if let sid = ev.agentSessionId, ctx.reviewers[sid] != nil {
return
}
let row: Row
@@ -437,10 +449,17 @@ final class TranscriptBuilder {
rowTask = ev.taskId ?? taskId
let own = rowTask == taskId
if ev.kind == "session.created" && str(p, "role") == "reviewer", let sid = ev.agentSessionId, !sid.isEmpty {
ctx.reviewers.insert(sid)
let row = Row(.reviewer(model: str(p, "model") ?? ""), taskId: rowTask, body: true)
row.status = "running"
row.active = true
ctx.reviewers[sid] = row
ctx.subagents[sid] = Subagent(row: row)
ctx.run = nil
put(row)
continue
}
if let sid = ev.agentSessionId, ctx.reviewers.contains(sid) {
if let sid = ev.agentSessionId, let reviewer = ctx.reviewers[sid] {
reviewerEvent(ev, reviewer, ctx.subagents[parent] == nil ? sid : parent)
continue
}
if ev.kind == "agent.vendor" {
@@ -449,7 +468,7 @@ final class TranscriptBuilder {
}
if ev.kind == "session.subagents" {
if int(p, "subagents") == 0 {
for sub in ctx.subagents.values where sub.row.active {
for sub in ctx.subagents.values where sub.row.active && !ctx.reviewers.values.contains(where: { $0 === sub.row }) {
endSubagent(sub.row, .error, "ended with its process")
}
}
@@ -517,11 +536,13 @@ final class TranscriptBuilder {
}
if ev.kind == "review.started" || ev.kind == "review.finished" || ev.kind == "review.finding" {
let r = try! decodePayload(ev.payload, as: Review.self)
ctx.snapshots[snapKey(r.snapshotN)]?.review = r
ctx.reviews[snapKey(r.snapshotN)] = r
ctx.reviews[reviewKey(r.snapshotN, r.stage)] = r
for row in ctx.reviewRows[r.id] ?? [] {
row.review = r
}
if let reviewer = ctx.reviewers[r.agentSessionId] {
reviewerState(reviewer, r)
}
if ev.kind == "review.finished" {
let rows = renderReview(r, ev.createdAt)
if !rows.isEmpty {
@@ -546,7 +567,7 @@ final class TranscriptBuilder {
}
let sub = ctx.subagents[parent]
if ev.kind == "agent.tool_call" && isSubagentCall(p) {
let row = renderSubagent(p)
let row = renderSubagent(p, parent)
row.started = ev.createdAt
if let sub {
sub.run = nil
@@ -562,7 +583,7 @@ final class TranscriptBuilder {
let row = Row(.findingResponse(snapshot: n, index: (int(input, "finding") ?? 0) - 1, response: str(input, "response") ?? "", reason: str(input, "reason") ?? ""), taskId: rowTask)
row.started = ev.createdAt
ctx.tools[jsString(p["id"])] = row
if let r = ctx.reviews[snapKey(n)], r.state == .done {
if let r = ctx.reviews[reviewKey(n, str(input, "reviewer") ?? "")], r.state == .done {
row.review = r
ctx.reviewRows[r.id, default: []].append(row)
}
@@ -572,7 +593,7 @@ final class TranscriptBuilder {
}
if ev.kind == "agent.tool_call" {
let name = toolLabel(jsString(p["name"]))
let row = renderToolCall(p)
let row = renderToolCall(p, parent)
row.started = ev.createdAt
if name == "ToolSearch" {
continue
@@ -580,7 +601,7 @@ final class TranscriptBuilder {
addToRun(row, Call(name: name, target: toolTarget(p["input"] ?? nil, worktree())), parent)
continue
}
guard let row = renderEvent(ev) else {
guard let row = renderEvent(ev, parent) else {
continue
}
if let sub {
@@ -653,6 +674,10 @@ final class TranscriptBuilder {
"\(rowTask ?? "")/\(n)"
}
private func reviewKey(_ n: Int, _ stage: String) -> String {
"\(snapKey(n))/\(stage)"
}
private func claudeTaskStatus(_ p: JSONDict) {
guard str(p, "type") == "system", let sub = ctx.subagents[jsString(p["tool_use_id"])] else {
return
@@ -685,36 +710,97 @@ final class TranscriptBuilder {
}
}
private func renderToolCall(_ p: JSONDict) -> Row {
private func renderToolCall(_ p: JSONDict, _ parent: String) -> Row {
let row = Row(.tool(name: toolLabel(jsString(p["name"])), brief: toolBrief(p["input"] ?? nil, worktree()), input: prettyJSON(p["input"] ?? nil)), taskId: rowTask)
row.tag = subagentTag(parentOf(p))
row.owner = ctx.subagents[parentOf(p)]?.row
row.tag = subagentTag(parent)
row.owner = ctx.subagents[parent]?.row
ctx.tools[jsString(p["id"])] = row
return row
}
private func renderSubagent(_ p: JSONDict) -> Row {
private func renderSubagent(_ p: JSONDict, _ parent: String) -> Row {
let input = dict(p, "input") ?? [:]
let id = jsString(p["id"])
let type = str(input, "subagent_type") ?? "subagent"
let row = Row(.subagent(title: "\(jsString(p["name"])) \(type)", description: str(input, "description") ?? "", prompt: parseMarkdown(str(input, "prompt") ?? "")), taskId: rowTask, body: true)
row.tag = subagentTag(parentOf(p))
row.owner = ctx.subagents[parentOf(p)]?.row
row.tag = subagentTag(parent)
row.owner = ctx.subagents[parent]?.row
ctx.tools[id] = row
ctx.subagents[id] = Subagent(row: row)
return row
}
private func reviewerEvent(_ ev: ByreEvent, _ reviewer: Row, _ parent: String) {
let p = ev.payload.value
let sub = ctx.subagents[parent]!
switch ev.kind {
case "agent.vendor":
claudeTaskStatus(p)
case "user.message":
addToRun(byrePrompt(str(p, "text") ?? "", ev.createdAt), Call(name: "byre", target: ""), parent)
case "agent.tool_call":
let name = toolLabel(jsString(p["name"]))
reviewer.text = "\(name) \(toolBrief(p["input"] ?? nil, worktree()))"
if isSubagentCall(p) {
let row = renderSubagent(p, parent)
row.started = ev.createdAt
if sub.row !== reviewer {
sub.run = nil
sub.row.body!.rows.append(row)
return
}
addToRun(row, Call(name: jsString(p["name"]), target: ""), parent)
return
}
let row = renderToolCall(p, parent)
row.started = ev.createdAt
if name != "ToolSearch" {
addToRun(row, Call(name: name, target: toolTarget(p["input"] ?? nil, worktree())), parent)
}
case "turn.ended":
for tool in ctx.tools.values where tool.result == nil && tool.within(reviewer) {
tool.unfinished = true
}
case "agent.message", "agent.thinking", "agent.tool_result", "session.state", "session.error", "session.rate_limited":
if ev.kind == "agent.message" && parent == ev.agentSessionId {
reviewer.text = trimmed(jsString(p["text"])).components(separatedBy: "\n").first ?? ""
}
guard let row = renderEvent(ev, parent) else {
return
}
sub.run = nil
sub.row.body!.rows.append(row)
default:
break
}
}
private func reviewerState(_ row: Row, _ r: Review) {
row.review = r
row.reviewOf = r.snapshotN
switch r.state {
case .done:
endSubagent(row, .done, reviewCounts(r))
row.text = ""
case .failed:
let superseded = r.error.hasPrefix("superseded")
endSubagent(row, superseded ? .done : .error, superseded ? "superseded" : "failed")
row.text = r.error
default:
break
}
}
private func renderReview(_ r: Review, _ when: Date) -> [Row] {
if r.state == .failed && r.agentSessionId.isEmpty {
return [Row(.note(Note(parts: [.text("\(r.stage) review of snapshot #\(r.snapshotN) failed: \(r.error)")], when: when, style: .error)), taskId: rowTask)]
}
let fs = findings(r)
if fs.isEmpty && r.state != .failed {
if fs.isEmpty {
return []
}
let outcome = r.state == .failed ? "failed: \(r.error)" : reviewCounts(r)
let parts: [NotePart] = [.text("review of snapshot #\(r.snapshotN): \(outcome)")]
let head = fs.isEmpty
? Row(.note(Note(parts: parts, when: when, style: r.state == .failed ? .error : .plain)), taskId: rowTask)
: Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: rowTask)
let parts: [NotePart] = [.text("\(r.stage) review of snapshot #\(r.snapshotN): \(reviewCounts(r))")]
let head = Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: rowTask)
let rows = [head] + fs.indices.map { Row(.finding(index: $0), taskId: rowTask) }
for row in rows {
row.review = r
@@ -724,7 +810,13 @@ final class TranscriptBuilder {
return rows
}
private func renderEvent(_ ev: ByreEvent) -> Row? {
private func byrePrompt(_ text: String, _ when: Date) -> Row {
let first = text.components(separatedBy: "\n").first ?? ""
let brief = first.count > 120 ? "\(first.prefix(120))…" : first
return Row(.byrePrompt(brief: brief, when: when, md: parseMarkdown(text)), taskId: rowTask)
}
private func renderEvent(_ ev: ByreEvent, _ parent: String) -> Row? {
let p = ev.payload.value
let when = ev.createdAt
func note(_ parts: [NotePart], _ style: NoteStyle = .plain, time: Bool = true) -> Row {
@@ -739,9 +831,7 @@ final class TranscriptBuilder {
}
let text = str(p, "text") ?? ""
if ev.actor == "byre" {
let first = text.components(separatedBy: "\n").first ?? ""
let brief = first.count > 120 ? "\(first.prefix(120))…" : first
let row = Row(.byrePrompt(brief: brief, when: when, md: parseMarkdown(text)), taskId: rowTask)
let row = byrePrompt(text, when)
row.hidden = bool(p, "continuation") || bool(p, "findings")
return row
}
@@ -750,9 +840,9 @@ final class TranscriptBuilder {
case "agent.message":
let text = jsString(p["text"])
if !parentOf(p).isEmpty {
if !parent.isEmpty {
let row = Row(.subagentMessage(when: when, md: parseMarkdown(text)), taskId: rowTask)
row.tag = subagentTag(parentOf(p))
row.tag = subagentTag(parent)
return row
}
if trimmed(trimmed(text).components(separatedBy: "\n").last ?? "") == quietReply {
@@ -764,7 +854,7 @@ final class TranscriptBuilder {
case "agent.thinking":
let row = Row(.thinking(md: parseMarkdown(jsString(p["text"]))), taskId: rowTask)
row.tag = subagentTag(parentOf(p))
row.tag = subagentTag(parent)
return row
case "agent.tool_result":
@@ -906,13 +996,11 @@ func findingCount(_ r: Review) -> Int {
r.state == .done ? findings(r).indices.filter { findingOpen(r, $0) }.count : 0
}
func latestReview(_ events: ArraySlice<ByreEvent>) -> Review? {
var best: Review?
func latestReviewedSnapshot(_ events: ArraySlice<ByreEvent>) -> Int? {
var best: Int?
for ev in events where isReviewEvent(ev) {
let r = try! decodePayload(ev.payload, as: Review.self)
if best == nil || r.snapshotN >= best!.snapshotN {
best = r
}
best = max(best ?? r.snapshotN, r.snapshotN)
}
return best
}
+471
View File
@@ -0,0 +1,471 @@
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
}
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 review = false
@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 || name.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.top, 16)
if let root {
if original == nil {
TextField("Name", text: $name)
.modifier(FieldStyle())
.padding(.top, 12)
} else {
Text(name)
.font(Fonts.font(13, weight: 700))
.textSelection(.enabled)
.padding(.top, 12)
}
TextField("Description", text: $description, axis: .vertical)
.modifier(FieldStyle())
.padding(.top, 8)
Picker("", selection: $review) {
Text("Runs on a brief, each stage its own task").tag(false)
Text("Reviews snapshots, each stage a reviewer").tag(true)
}
.pickerStyle(.radioGroup)
.labelsHidden()
.padding(.top, 8)
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, kind: review ? .review : nil, steps: $0.children.map(\.step)) }
}
private var changed: Bool {
original == nil || current != loaded
}
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), headers: .init(accept: [.init(contentType: .json)])).ok.body.json
name = original
description = def.description ?? ""
review = def.kind == .review
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 }
let target = name.trimmingCharacters(in: .whitespaces)
do {
_ = try await store.api.client.putWorkflow(path: .init(name: target), body: .json(def)).ok.body.json
failure = ""
loaded = def
if original == nil {
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()
Button("Move up") { move(-1) }
.disabled(node.index == 0)
Button("Move down") { move(1) }
.disabled(node.index == node.parent!.children.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))
}
}
+51 -11
View File
@@ -1,15 +1,41 @@
import ByreAPI
import SwiftUI
private enum Editing: Equatable {
case none, new, existing(String)
}
struct WorkflowsView: View {
@ViewState private var editing = Editing.none
var body: some View {
switch editing {
case .none:
list
case .new:
WorkflowEditor(original: nil) { editing = .none }
case .existing(let name):
WorkflowEditor(original: name) { editing = .none }
.id(name)
}
}
private var list: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Workflows run a brief through stages, each its own task. Run asks your lead to start one, so you can add the project and the brief first.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 16)
WorkflowList()
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text("Workflows run a brief through stages, each its own task. Run asks your lead to start one, so you can add the project and the brief first.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
Button("new") {
editing = .new
}
.buttonStyle(SmallLinkButtonStyle())
.help("Write a new workflow")
}
.padding(.top, 16)
WorkflowList { editing = .existing($0) }
}
.padding(.horizontal, 16)
.padding(.top, 8)
@@ -22,6 +48,7 @@ struct WorkflowsView: View {
}
private struct WorkflowList: View {
let edit: (String) -> Void
@Environment(Store.self) private var store
@ViewState private var list: [WorkflowSummary]?
@ViewState private var failure: String?
@@ -35,7 +62,7 @@ private struct WorkflowList: View {
hint("No saved workflows. Ask your lead to write one.")
} else {
ForEach(list.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }, id: \.name) { w in
WorkflowRow(workflow: w)
WorkflowRow(workflow: w) { edit(w.name) }
}
}
} else if let failure {
@@ -81,14 +108,20 @@ private struct WorkflowList: View {
private struct WorkflowRow: View {
let workflow: WorkflowSummary
let edit: () -> Void
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(workflow.name)
.font(Fonts.font(13, weight: 700))
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(workflow.name)
.font(Fonts.font(13, weight: 700))
if workflow.kind == .review {
Text("reviews snapshots").font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
if !workflow.description.isEmpty {
Text(workflow.description)
.foregroundStyle(Palette.muted)
@@ -97,11 +130,18 @@ private struct WorkflowRow: View {
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
Button("run") {
run()
Button("edit") {
edit()
}
.buttonStyle(SmallLinkButtonStyle())
.help("Open the Lead tab with a request to run this workflow in its composer")
.help("See and change this workflow's steps and prompts")
if workflow.kind == .task {
Button("run") {
run()
}
.buttonStyle(SmallLinkButtonStyle())
.help("Open the Lead tab with a request to run this workflow in its composer")
}
}
.padding(.vertical, 6)
.overlay(alignment: .top) {