Move questions and reviews into the transcript; agents answer findings

This commit is contained in:
Ian Gulliver
2026-10-19 09:14:21 -07:00
parent eb7e0a13c4
commit 27ff0f457b
21 changed files with 903 additions and 801 deletions
+72 -213
View File
@@ -2,18 +2,10 @@ import ByreAPI
import SwiftUI
typealias FindingState = Components.Schemas.FindingState
typealias FindingResponse = Components.Schemas.FindingResponse
private let findingHead = Re(#"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@"#)
private func stateText(_ r: Review) -> String {
switch r.state {
case .running: "reviewing on \(r.vendor) (\(r.model))…"
case .done: reviewCounts(r)
case .failed: "failed"
case .skipped: "skipped: no changes"
}
}
@MainActor
private enum SnapshotPatches {
static var changes: [String: Task<TaskChanges, any Error>] = [:]
@@ -78,160 +70,30 @@ private func linesAround(_ patch: String, _ line: Int, _ end: Int) -> [(kind: Pa
return lines.filter { $0.at >= line - 3 && $0.at <= end + 3 }
}
struct ReviewsView: View {
@Environment(Store.self) private var store
@ViewState private var reviews: [Review]?
@ViewState private var error = ""
@ViewState private var target = 0
var body: some View {
VStack(spacing: 0) {
content
}
.frame(maxHeight: .infinity, alignment: .top)
.task(id: store.selectedTaskId) {
target += 1
reviews = nil
error = ""
await load()
let off = store.selectedFeed?.appended.add { events in
if events.contains(where: isReviewEvent) {
Task {
await load()
}
}
}
await untilCancelled()
off?()
target += 1
}
}
private func load() async {
guard let taskId = store.selectedTaskId else {
return
}
target += 1
let started = target
do {
let fresh = try await store.api.client.listReviews(path: .init(taskId: taskId)).ok.body.json
if started == target {
reviews = fresh
error = ""
}
} catch {
if started == target {
reviews = nil
self.error = errorText(error)
}
}
}
@ViewBuilder
private var content: some View {
if let taskId = store.selectedTaskId {
if !error.isEmpty {
EmptyText(text: error, error: true)
} else if let reviews {
if let lastReview = reviews.last {
let n = store.reviewed ?? lastReview.snapshotN
let shown = reviews.last { $0.snapshotN == n }
let ns = Array(Set([n] + reviews.map(\.snapshotN))).sorted(by: >)
let next = ns.filter { $0 > n }.last
let prev = ns.first { $0 < n }
HStack(spacing: 8) {
Picker("", selection: Binding(get: { n }, set: { store.pickReview($0) })) {
ForEach(ns, id: \.self) { s in
Text("snapshot #\(s)").tag(s)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Snapshot whose review to show")
step(prev ?? n - 1, "← #\(prev ?? n - 1)", ns)
step(next ?? n + 1, "#\(next ?? n + 1) →", ns)
}
.modifier(HeaderBar())
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if let shown {
ReviewCard(review: shown, taskId: taskId)
.id(shown.id)
} else {
Muted(text: "No review of snapshot #\(n) yet.")
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
}
} else {
EmptyText(text: "No reviews yet. Each new snapshot of the task is reviewed by an agent from the other vendor.")
}
} else {
EmptyText(text: "Loading…")
}
} else {
EmptyText(text: "No task selected.")
}
}
private func step(_ to: Int, _ label: String, _ ns: [Int]) -> some View {
Button(label) {
store.pickReview(to)
}
.buttonStyle(SmallLinkButtonStyle(color: Palette.text))
.disabled(!ns.contains(to))
}
func findingResponse(_ r: Review, _ index: Int) -> FindingResponse? {
r.findingResponses.additionalProperties[String(index)]
}
struct ReviewCard: View {
let review: Review
let taskId: String?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("snapshot #\(review.snapshotN)")
.font(Fonts.font(11, weight: 700))
.foregroundStyle(Palette.text)
Text(stateText(review))
.foregroundStyle(review.state == .failed ? Palette.warn : Palette.muted)
Spacer(minLength: 0)
Text(ago(review.createdAt))
.help(isoText(review.createdAt))
if review.state == .running {
StopReviewButton(reviewId: review.id)
}
}
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
if review.state == .failed {
Text(review.error)
.foregroundStyle(Palette.danger)
.textSelection(.enabled)
}
if review.state == .done {
ReviewResult(review: review, taskId: taskId)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
}
func findingOpen(_ r: Review, _ index: Int) -> Bool {
r.findingStates.additionalProperties[String(index)] == nil && findingResponse(r, index) == nil
}
private struct StopReviewButton: View {
func findingWhere(_ f: JSONDict) -> String {
let path = str(f, "path") ?? ""
let line = max(0, int(f, "line") ?? 0)
let endRaw = int(f, "end_line") ?? 0
let end = endRaw > line ? endRaw : line
return path.isEmpty ? "" : "\(path)\(line > 0 ? ":\(line)\(end > line ? "–\(end)" : "")" : "")"
}
struct StopReviewButton: View {
let reviewId: String
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var busy = false
var body: some View {
Button("Stop") {
Button("stop") {
busy = true
Task {
do {
@@ -242,51 +104,32 @@ private struct StopReviewButton: View {
}
}
}
.buttonStyle(WebButtonStyle(danger: true))
.buttonStyle(SmallLinkButtonStyle(color: Palette.danger))
.disabled(busy)
.help("End the reviewer and record this review as failed")
}
}
private struct ReviewResult: View {
let review: Review
let taskId: String?
var body: some View {
let res = review.result?.value ?? [:]
let fs = findings(review)
let md = MDContext(taskId: taskId)
VStack(alignment: .leading, spacing: 8) {
MarkdownView(blocks: parseMarkdown(jsString(res["summary"] ?? "")), ctx: md)
ReviewImages(value: res["images"] ?? nil)
if !fs.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(fs.enumerated()), id: \.offset) { i, f in
FindingRow(review: review, index: i, finding: f, taskId: taskId)
}
}
}
}
}
}
private struct FindingRow: View {
struct FindingRow: View {
let review: Review
let index: Int
let finding: JSONDict
let taskId: String?
var body: some View {
let f = finding
let f = findings(review)[index]
let severity = jsString(f["severity"] ?? "")
let path = str(f, "path") ?? ""
let line = max(0, int(f, "line") ?? 0)
let endRaw = int(f, "end_line") ?? 0
let end = endRaw > line ? endRaw : line
let whereText = path.isEmpty ? "" : "\(path)\(line > 0 ? ":\(line)\(end > line ? "–\(end)" : "")" : "")"
let whereText = findingWhere(f)
let md = MDContext(taskId: taskId)
let response = findingResponse(review, index)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("\(index + 1).")
.font(Fonts.font(11, weight: 700))
.foregroundStyle(Palette.muted)
Text(severity)
.font(Fonts.font(11, weight: 700))
.foregroundStyle(severity == "critical" || severity == "high" ? Palette.danger : severity == "medium" ? Palette.warn : Palette.muted)
@@ -301,23 +144,33 @@ private struct FindingRow: View {
}
Spacer(minLength: 0)
if let taskId {
FindingActions(taskId: taskId, review: review, index: index, finding: f, whereText: whereText)
FindingActions(taskId: taskId, review: review, index: index)
}
}
if let taskId, !path.isEmpty, line > 0 {
FindingDiff(taskId: taskId, n: review.snapshotN, path: path, line: line, end: end)
if let response {
Text("agent \(response.response.rawValue): \(response.reason)")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
MarkdownView(blocks: parseMarkdown(jsString(f["detail"] ?? "")), ctx: md)
ReviewImages(value: f["images"] ?? nil)
Group {
if let taskId, !path.isEmpty, line > 0 {
FindingDiff(taskId: taskId, n: review.snapshotN, path: path, line: line, end: end)
}
MarkdownView(blocks: parseMarkdown(jsString(f["detail"] ?? "")), ctx: md)
ReviewImages(value: f["images"] ?? nil)
}
.opacity(response == nil ? 1 : 0.5)
}
.padding(.top, 6)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
.padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.border).frame(width: 1)
}
}
}
private struct ReviewImages: View {
struct ReviewImages: View {
let value: (any Sendable)?
var body: some View {
@@ -399,36 +252,42 @@ private struct DiffBox: ViewModifier {
}
}
private struct FindingActions: View {
struct FindingActions: View {
let taskId: String
let review: Review
let index: Int
let finding: JSONDict
let whereText: String
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var done: FindingState?
@ViewState private var busy = false
var body: some View {
if let s = done ?? review.findingStates.additionalProperties[String(index)] {
Text(s == .fixed ? "fix sent" : "ignored")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
} else {
HStack(spacing: 6) {
Button("fix") {
fix()
let response = findingResponse(review, index)?.response
let state = review.findingStates.additionalProperties[String(index)]
Group {
if let state {
Text(state == .fixed ? "fix sent" : "ignored")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
} else if response != .fixing {
HStack(spacing: 6) {
Button("fix") {
fix()
}
.buttonStyle(SmallLinkButtonStyle())
.help(response == .ignoring ? "Overrule the agent: send this finding to it to fix" : "Send this finding to the task's agent to fix")
if response == nil {
Button("ignore") {
ignore()
}
.buttonStyle(SmallLinkButtonStyle())
.help("Mark this finding done without fixing it; Merge won't warn about it")
}
}
.buttonStyle(SmallLinkButtonStyle())
.help("Send this finding to the task's agent to fix")
Button("ignore") {
ignore()
}
.buttonStyle(SmallLinkButtonStyle())
.help("Mark this finding done without fixing it; Merge won't warn about it")
.disabled(busy)
}
.disabled(busy)
}
.onChange(of: state) {
busy = false
}
}
@@ -437,8 +296,9 @@ private struct FindingActions: View {
}
private func fix() {
let f = finding
let text = "Fix this finding from the review of snapshot #\(review.snapshotN):\n\n**[\(jsString(f["severity"] ?? ""))] \(jsString(f["title"] ?? ""))**\(whereText.isEmpty ? "" : " (`\(whereText)`)")\n\n\(jsString(f["detail"] ?? ""))"
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"] ?? ""))"
busy = true
Task {
do {
@@ -448,10 +308,10 @@ private struct FindingActions: View {
overlays.showAlert(errorText(error))
return
}
done = .fixed
do {
try await mark(.fixed)
} catch {
busy = false
overlays.showAlert("fix sent, but not recorded: \(errorText(error))")
}
}
@@ -462,7 +322,6 @@ private struct FindingActions: View {
Task {
do {
try await mark(.ignored)
done = .ignored
} catch {
busy = false
overlays.showAlert(errorText(error))