Add review workflows, tree-shaped workflows and a workflow editor
This commit is contained in:
+18
-58
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user