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)")
}
}