import ByreAPI import SwiftUI typealias FindingState = Components.Schemas.FindingState typealias FindingResponse = Components.Schemas.FindingResponse private let findingHead = Re(#"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@"#) @MainActor private enum SnapshotPatches { static var changes: [String: Task] = [:] static var patches: [String: Task] = [:] static func patch(_ api: API, _ taskId: String, _ n: Int, _ path: String) async throws -> String? { let key = "\(api.origin.absoluteString)/\(taskId)/\(n)/\(path)" if let cached = patches[key] { return try await cached.value } let fetched = Task { let ck = "\(api.origin.absoluteString)/\(taskId)/\(n)" let pending = changes[ck] ?? Task { try await api.client.getTaskChanges(path: .init(taskId: taskId), query: .init(snapshot: n)).ok.body.json } changes[ck] = pending let c: TaskChanges do { c = try await pending.value } catch { changes[ck] = nil throw error } guard let f = c.files.first(where: { $0.path == path }), !f.binary else { return nil } return try await api.client.getTaskDiff(path: .init(taskId: taskId), query: .init(base: c.base, tree: c.tree, path: f.path, oldPath: f.oldPath)).ok.body.json.patch } patches[key] = fetched do { return try await fetched.value } catch { patches[key] = nil throw error } } } private func linesAround(_ patch: String, _ line: Int, _ end: Int) -> [(kind: PatchLine, text: String, at: Int)] { var lines: [(kind: PatchLine, text: String, at: Int)] = [] var n = 0 var inHunk = false for l in patch.components(separatedBy: "\n") { if let m = findingHead.exec(l) { n = Int(m[1]!) ?? 0 inHunk = true continue } if !inHunk { continue } if l.hasPrefix("+") { lines.append((.add, l, n)) n += 1 } else if l.hasPrefix("-") { lines.append((.del, l, n)) } else if l.hasPrefix(" ") { lines.append((.ctx, l, n)) n += 1 } } return lines.filter { $0.at >= line - 3 && $0.at <= end + 3 } } func findingResponse(_ r: Review, _ index: Int) -> FindingResponse? { r.findingResponses.additionalProperties[String(index)] } func findingOpen(_ r: Review, _ index: Int) -> Bool { r.findingStates.additionalProperties[String(index)] == nil && findingResponse(r, index) == nil } 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 FindingRow: View { let review: Review let index: Int let taskId: String? var body: some View { 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 = 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) if let category = str(f, "category"), !category.isEmpty { Text(category).font(Fonts.font(11)).foregroundStyle(Palette.muted) } Text(jsString(f["title"] ?? "")) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) if !whereText.isEmpty { Text(whereText).font(Fonts.font(11)).foregroundStyle(Palette.muted).textSelection(.enabled) } Spacer(minLength: 0) if let taskId { FindingActions(taskId: taskId, review: review, index: index) } } if let response { Text("agent \(response.response.rawValue): \(response.reason)") .font(Fonts.font(11)) .foregroundStyle(Palette.muted) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } 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(.leading, 10) .overlay(alignment: .leading) { Rectangle().fill(Palette.border).frame(width: 1) } } } struct ReviewImages: View { let value: (any Sendable)? var body: some View { let imgs = (value as? [(any Sendable)?] ?? []).map { $0 as? JSONDict ?? [:] } if !imgs.isEmpty { FlowLayout(spacing: 10) { ForEach(Array(imgs.enumerated()), id: \.offset) { _, img in let blob = jsString(img["blob"] ?? "") let caption = str(img, "caption") ?? "" VStack(alignment: .leading, spacing: 2) { ByreImage(source: .blob(blob), src: "/api/blobs/\(blob)", maxHeight: 360) if !caption.isEmpty { Text(caption).font(Fonts.font(11)).foregroundStyle(Palette.muted) } } } } .padding(.vertical, 8) } } } private struct FindingDiff: View { let taskId: String let n: Int let path: String let line: Int let end: Int @Environment(Store.self) private var store @ViewState private var rows: [DiffRow]? @ViewState private var failure: String? var body: some View { Group { if let failure { Text("diff not available: \(failure)") .font(Fonts.font(11)) .foregroundStyle(Palette.muted) .padding(.horizontal, 10) .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) .modifier(DiffBox()) } else if let rows, !rows.isEmpty { DiffLines(rows: rows) .modifier(DiffBox()) } } .task(id: "\(taskId)/\(n)/\(path)/\(line)/\(end)") { failure = nil do { let patch = try await SnapshotPatches.patch(store.api, taskId, n, path) let lines = patch.map { linesAround($0, line, end) } ?? [] var out: [DiffRow] = [] for (i, d) in lines.enumerated() { if i > 0 { let prev = lines[i - 1] if d.kind != .del && prev.kind != .del && d.at > prev.at + 1 { out.append(DiffRow(text: "⋯", kind: .hunk)) } } let mark = d.kind != .del && d.at >= line && d.at <= end out.append(DiffRow(text: d.text, kind: d.kind, lineno: d.kind == .del ? "" : String(d.at), mark: mark)) } rows = out } catch { failure = errorText(error) } } } } private struct DiffBox: ViewModifier { func body(content: Content) -> some View { content .background(Palette.bg) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) .padding(.top, 2) } } struct FindingActions: View { let taskId: String let review: Review let index: Int @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var busy = false var body: some View { 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") } } .disabled(busy) } } .onChange(of: state) { busy = false } } private func mark(_ s: FindingState) async throws { _ = try await store.api.client.setFindingState(path: .init(reviewId: review.id, index: index), body: .json(.init(state: s))).ok } 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"] ?? ""))" busy = true Task { do { try await store.messageTask(taskId, text) } catch { busy = false overlays.showAlert(errorText(error)) return } do { try await mark(.fixed) } catch { busy = false overlays.showAlert("fix sent, but not recorded: \(errorText(error))") } } } private func ignore() { busy = true Task { do { try await mark(.ignored) } catch { busy = false overlays.showAlert(errorText(error)) } } } }