Move questions and reviews into the transcript; agents answer findings
This commit is contained in:
@@ -38,7 +38,6 @@ func untilCancelled() async {
|
||||
@Observable
|
||||
final class ChangesModel {
|
||||
let store: Store
|
||||
let compact: Bool
|
||||
private(set) var changes: TaskChanges?
|
||||
private(set) var error = ""
|
||||
private(set) var diffs: [String: Result<String, ApiError>] = [:]
|
||||
@@ -52,20 +51,19 @@ final class ChangesModel {
|
||||
@ObservationIgnored private var target = 0
|
||||
@ObservationIgnored private var fetching: Set<String> = []
|
||||
|
||||
init(_ store: Store, compact: Bool) {
|
||||
init(_ store: Store) {
|
||||
self.store = store
|
||||
self.compact = compact
|
||||
open = store.changesMemory.open
|
||||
range = store.changesMemory.range
|
||||
pinRange = store.changesMemory.pinRange
|
||||
}
|
||||
|
||||
var pinned: Int? {
|
||||
compact ? nil : store.pinned
|
||||
store.pinned
|
||||
}
|
||||
|
||||
var mode: ChangeMode {
|
||||
compact ? .main : pinned != nil ? pinRange : range
|
||||
pinned != nil ? pinRange : range
|
||||
}
|
||||
|
||||
var empty: String {
|
||||
@@ -217,7 +215,6 @@ final class ChangesModel {
|
||||
}
|
||||
|
||||
struct ChangesView: View {
|
||||
var compact = false
|
||||
@Environment(Store.self) private var store
|
||||
@ViewState private var model: ChangesModel?
|
||||
|
||||
@@ -225,14 +222,12 @@ struct ChangesView: View {
|
||||
VStack(spacing: 0) {
|
||||
if let model {
|
||||
ChangesHead(model: model)
|
||||
if !compact {
|
||||
ChangesList(model: model)
|
||||
}
|
||||
ChangesList(model: model)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: compact ? nil : .infinity, alignment: .top)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.task(id: store.selectedTaskId) {
|
||||
let m = model ?? ChangesModel(store, compact: compact)
|
||||
let m = model ?? ChangesModel(store)
|
||||
model = m
|
||||
m.retarget()
|
||||
let off = store.selectedFeed?.appended.add { [weak m] events in
|
||||
@@ -243,9 +238,7 @@ struct ChangesView: View {
|
||||
m.stop()
|
||||
}
|
||||
.onChange(of: store.pinned) {
|
||||
if !compact {
|
||||
model?.retarget()
|
||||
}
|
||||
model?.retarget()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,7 +260,7 @@ private struct ChangesHead: View {
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if !model.compact, let taskId = store.selectedTaskId, let drafts = store.drafts {
|
||||
if let taskId = store.selectedTaskId, let drafts = store.drafts {
|
||||
let n = drafts.lineComments(taskId).filter { !trimmed($0.text).isEmpty }.count
|
||||
if n > 0 {
|
||||
Button("Send \(n) \(n == 1 ? "comment" : "comments")") {
|
||||
@@ -317,29 +310,27 @@ private struct ChangesHead: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var toggle: some View {
|
||||
if !model.compact {
|
||||
HStack(spacing: 4) {
|
||||
if model.pinned != nil {
|
||||
RangeTabButton(label: "Since main", active: model.pinRange == .main) {
|
||||
model.setPinRange(.main)
|
||||
}
|
||||
RangeTabButton(label: "This step", active: model.pinRange == .step) {
|
||||
model.setPinRange(.step)
|
||||
}
|
||||
RangeTabButton(label: "Live", active: false, help: "Back to the live worktree") {
|
||||
store.pinSnapshot(nil)
|
||||
}
|
||||
} else {
|
||||
RangeTabButton(label: "Since main", active: model.range == .main) {
|
||||
model.setRange(.main)
|
||||
}
|
||||
RangeTabButton(label: "This turn", active: model.range == .turn) {
|
||||
model.setRange(.turn)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
if model.pinned != nil {
|
||||
RangeTabButton(label: "Since main", active: model.pinRange == .main) {
|
||||
model.setPinRange(.main)
|
||||
}
|
||||
RangeTabButton(label: "This step", active: model.pinRange == .step) {
|
||||
model.setPinRange(.step)
|
||||
}
|
||||
RangeTabButton(label: "Live", active: false, help: "Back to the live worktree") {
|
||||
store.pinSnapshot(nil)
|
||||
}
|
||||
} else {
|
||||
RangeTabButton(label: "Since main", active: model.range == .main) {
|
||||
model.setRange(.main)
|
||||
}
|
||||
RangeTabButton(label: "This turn", active: model.range == .turn) {
|
||||
model.setRange(.turn)
|
||||
}
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+237
-43
@@ -7,6 +7,13 @@ enum ChatKind: Equatable {
|
||||
case task(String?)
|
||||
}
|
||||
|
||||
struct AttentionGroup: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let help: String
|
||||
let rows: [Int]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ChatModel {
|
||||
@@ -28,6 +35,7 @@ final class ChatModel {
|
||||
@ObservationIgnored private var activeObserver: NotificationCenter.ObservationToken?
|
||||
@ObservationIgnored private var keyObserver: NotificationCenter.ObservationToken?
|
||||
@ObservationIgnored private var started = false
|
||||
@ObservationIgnored private var cursors: [String: Int] = [:]
|
||||
|
||||
init(_ kind: ChatKind, _ store: Store) {
|
||||
self.kind = kind
|
||||
@@ -231,21 +239,112 @@ final class ChatModel {
|
||||
}
|
||||
|
||||
func jump() {
|
||||
guard let target = store.jump, target.taskId == taskId else {
|
||||
guard let j = store.jump, j.taskId == taskId else {
|
||||
return
|
||||
}
|
||||
let rows = builder.root.rows
|
||||
guard let i = rows.firstIndex(where: { $0.summarySeq == target.seq }) else {
|
||||
return
|
||||
switch j.target {
|
||||
case .summary(let seq):
|
||||
guard let i = rows.firstIndex(where: { $0.summarySeq == seq }) else {
|
||||
return
|
||||
}
|
||||
store.jump = nil
|
||||
stuck = false
|
||||
switch i > 0 ? rows[i - 1].kind : nil {
|
||||
case .agent, .subagentMessage:
|
||||
requestScroll(rows[i - 1].id, .top)
|
||||
default:
|
||||
requestScroll(rows[i].id, .bottom)
|
||||
}
|
||||
case .question(let id):
|
||||
guard let row = rows.first(where: { $0.question?.id == id }) else {
|
||||
return
|
||||
}
|
||||
store.jump = nil
|
||||
scrollTo(row.id)
|
||||
case .review(let n):
|
||||
guard let row = reviewRow(n) else {
|
||||
return
|
||||
}
|
||||
store.jump = nil
|
||||
scrollTo(row.id)
|
||||
}
|
||||
store.jump = nil
|
||||
}
|
||||
|
||||
private func reviewRow(_ n: Int) -> Row? {
|
||||
let rows = builder.root.rows
|
||||
return rows.first { $0.reviewOf == n } ?? rows.first { r in
|
||||
if case .snapshot(let s) = r.kind {
|
||||
return s.n == n
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func showReview(_ n: Int) {
|
||||
if let row = reviewRow(n) {
|
||||
scrollTo(row.id)
|
||||
}
|
||||
}
|
||||
|
||||
func showFinding(_ reviewId: String, _ index: Int) {
|
||||
let row = builder.root.rows.first { r in
|
||||
if case .finding(let i) = r.kind {
|
||||
return i == index && r.review?.id == reviewId
|
||||
}
|
||||
return false
|
||||
}
|
||||
if let row {
|
||||
scrollTo(row.id)
|
||||
}
|
||||
}
|
||||
|
||||
func attention() -> [AttentionGroup] {
|
||||
guard let taskId else {
|
||||
return []
|
||||
}
|
||||
let latest = latestReview(store.transcript)
|
||||
let active = store.findTask(taskId)?.status == .active
|
||||
var questions: [Int] = []
|
||||
var open: [Int] = []
|
||||
var ready: [Int] = []
|
||||
for row in builder.root.rows where !row.hidden {
|
||||
switch row.kind {
|
||||
case .question where row.question?.state == .open:
|
||||
questions.append(row.id)
|
||||
case .finding(let i):
|
||||
if let r = row.review, r.id == latest?.id, findingOpen(r, i) {
|
||||
open.append(row.id)
|
||||
}
|
||||
case .ready where row.id == builder.lastReady && active:
|
||||
ready.append(row.id)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
var groups: [AttentionGroup] = []
|
||||
if !questions.isEmpty {
|
||||
groups.append(AttentionGroup(id: "questions", label: questions.count == 1 ? "1 question" : "\(questions.count) questions", help: "Scroll to the next open question", rows: questions))
|
||||
}
|
||||
if !open.isEmpty {
|
||||
groups.append(AttentionGroup(id: "findings", label: open.count == 1 ? "1 finding" : "\(open.count) findings", help: "Scroll to the next finding of the latest review that nobody has answered", rows: open))
|
||||
}
|
||||
if !ready.isEmpty {
|
||||
groups.append(AttentionGroup(id: "ready", label: "ready to merge", help: "Scroll to the agent's ready-for-merge overview", rows: ready))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func cycle(_ g: AttentionGroup) {
|
||||
let next = (cursors[g.id] ?? -1) + 1
|
||||
let i = next < g.rows.count ? next : 0
|
||||
cursors[g.id] = i
|
||||
scrollTo(g.rows[i])
|
||||
}
|
||||
|
||||
private func scrollTo(_ id: Int) {
|
||||
stuck = false
|
||||
switch i > 0 ? rows[i - 1].kind : nil {
|
||||
case .agent, .subagentMessage:
|
||||
requestScroll(rows[i - 1].id, .top)
|
||||
default:
|
||||
requestScroll(rows[i].id, .bottom)
|
||||
}
|
||||
requestScroll(id, .top)
|
||||
}
|
||||
|
||||
private func requestScroll(_ id: Int, _ anchor: UnitPoint) {
|
||||
@@ -360,7 +459,7 @@ struct ChatView: View {
|
||||
.onDisappear {
|
||||
model?.stop()
|
||||
}
|
||||
.onChange(of: store.jump?.seq) {
|
||||
.onChange(of: store.jump) {
|
||||
model?.jump()
|
||||
}
|
||||
}
|
||||
@@ -702,6 +801,57 @@ private struct TranscriptScroll: View {
|
||||
Rectangle().stroke(running ? accent : .clear, lineWidth: 1)
|
||||
.animation(.easeInOut(duration: 0.3), value: running)
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
AttentionBar(model: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttentionBar: View {
|
||||
let model: ChatModel
|
||||
|
||||
var body: some View {
|
||||
let groups = model.attention()
|
||||
if !groups.isEmpty {
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
ForEach(groups) { g in
|
||||
AttentionButton(group: g) {
|
||||
model.cycle(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.padding(.trailing, 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttentionButton: View {
|
||||
let group: AttentionGroup
|
||||
let action: () -> Void
|
||||
@ViewState private var hover = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(group.label)
|
||||
.font(Fonts.font(11, weight: 700))
|
||||
.foregroundStyle(Palette.warn)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background {
|
||||
ZStack {
|
||||
Palette.bg
|
||||
Palette.warnTint
|
||||
}
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.overlay(Capsule().stroke(hover ? Palette.accent : Palette.warn.opacity(0.55)))
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.fixedSize()
|
||||
.help("\(group.help); click again for the next")
|
||||
.onHover { hover = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1034,13 +1184,24 @@ struct RowView: View {
|
||||
case .review(let note, let blocks):
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
NoteView(note: note, cancelVisible: false)
|
||||
MarkdownView(blocks: blocks, ctx: md)
|
||||
.padding(.leading, 10)
|
||||
.overlay(alignment: .leading) {
|
||||
Rectangle().fill(Palette.border).frame(width: 1)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
MarkdownView(blocks: blocks, ctx: md)
|
||||
ReviewImages(value: row.review?.result?.value["images"] ?? nil)
|
||||
}
|
||||
.padding(.leading, 10)
|
||||
.overlay(alignment: .leading) {
|
||||
Rectangle().fill(Palette.border).frame(width: 1)
|
||||
}
|
||||
}
|
||||
|
||||
case .finding(let index):
|
||||
if let review = row.review {
|
||||
FindingRow(review: review, index: index, taskId: row.taskId)
|
||||
}
|
||||
|
||||
case .findingResponse(let snapshot, let index, let response, let reason):
|
||||
FindingResponseView(row: row, model: model, snapshot: snapshot, index: index, response: response, reason: reason)
|
||||
|
||||
case .ready(let when, let blocks):
|
||||
ReadyBubble(when: when, blocks: blocks, taskId: row.taskId, latest: row.id == model.builder.lastReady, unmerged: model.hasUnmerged)
|
||||
|
||||
@@ -1061,7 +1222,9 @@ struct RowView: View {
|
||||
}
|
||||
|
||||
case .snapshot(let snap):
|
||||
SnapshotRow(snap: snap, review: row.review, taskId: row.taskId)
|
||||
SnapshotRow(snap: snap, review: row.review, taskId: row.taskId) {
|
||||
model.showReview(snap.n)
|
||||
}
|
||||
|
||||
case .question:
|
||||
if let item = row.question {
|
||||
@@ -1073,39 +1236,34 @@ struct RowView: View {
|
||||
|
||||
private struct QuestionBubble: View {
|
||||
let item: InboxItem
|
||||
@Environment(Store.self) private var store
|
||||
@ViewState private var hover = false
|
||||
|
||||
var body: some View {
|
||||
let open = item.state == .open
|
||||
let tone = open ? Palette.warn : Palette.muted
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Text("asked")
|
||||
WhenText(when: item.createdAt)
|
||||
Group {
|
||||
if open {
|
||||
QuestionCard(item: item)
|
||||
.id("\(item.id)/\(item.state.rawValue)/\(item.responses.count)")
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Text("asked")
|
||||
WhenText(when: item.createdAt)
|
||||
}
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(Palette.muted)
|
||||
MarkdownView(blocks: parseMarkdown(jsString(item.payload.value["question"] ?? "")), ctx: MDContext(taskId: item.taskId))
|
||||
Text(status)
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(Palette.muted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(Palette.muted)
|
||||
MarkdownView(blocks: parseMarkdown(jsString(item.payload.value["question"] ?? "")), ctx: MDContext(taskId: item.taskId))
|
||||
Text(status)
|
||||
.font(Fonts.font(11, weight: open ? 700 : 400))
|
||||
.foregroundStyle(tone)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(open ? Palette.warnTint : Palette.warnFaint, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(hover ? Palette.accent : open ? Palette.warn.opacity(0.55) : Palette.border))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
store.showQuestion(item.taskId, item.id)
|
||||
}
|
||||
.environment(\.choiceTap, { [store, item] in
|
||||
store.showQuestion(item.taskId, item.id)
|
||||
})
|
||||
.onHover { hover = $0 }
|
||||
.help("Show this question in the Questions tab")
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(open ? Palette.warn.opacity(0.55) : Palette.border))
|
||||
}
|
||||
|
||||
private var status: String {
|
||||
@@ -1132,6 +1290,37 @@ private struct QuestionBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct FindingResponseView: View {
|
||||
let row: Row
|
||||
let model: ChatModel
|
||||
let snapshot: Int
|
||||
let index: Int
|
||||
let response: String
|
||||
let reason: String
|
||||
|
||||
var body: some View {
|
||||
let title = row.review.flatMap { r in findings(r).indices.contains(index) ? str(findings(r)[index], "title") : nil } ?? ""
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("\(response) finding \(index + 1)\(title.isEmpty ? "" : ", \(title),") of snapshot #\(snapshot)'s review: \(reason)")
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(Palette.muted)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: 0)
|
||||
if let review = row.review, findings(review).indices.contains(index) {
|
||||
Button("show") {
|
||||
model.showFinding(review.id, index)
|
||||
}
|
||||
.buttonStyle(SmallLinkButtonStyle())
|
||||
.help("Scroll to the finding")
|
||||
if response == FindingResponse.ResponsePayload.ignoring.rawValue, let taskId = row.taskId {
|
||||
FindingActions(taskId: taskId, review: review, index: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadyBubble: View {
|
||||
let when: Date
|
||||
let blocks: [MDBlock]
|
||||
@@ -1328,6 +1517,7 @@ private struct SnapshotRow: View {
|
||||
let snap: Snapshot
|
||||
let review: Review?
|
||||
let taskId: String?
|
||||
let showReview: () -> Void
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(Overlays.self) private var overlays
|
||||
@ViewState private var forking = false
|
||||
@@ -1363,6 +1553,9 @@ private struct SnapshotRow: View {
|
||||
.help("Start a new task from this snapshot, its agent continuing the conversation as it stood here")
|
||||
if snap.trigger != .answer {
|
||||
reviewButton
|
||||
if let review, review.state == .running {
|
||||
StopReviewButton(reviewId: review.id)
|
||||
}
|
||||
}
|
||||
Rectangle().fill(Palette.border).frame(height: 1)
|
||||
}
|
||||
@@ -1374,7 +1567,7 @@ private struct SnapshotRow: View {
|
||||
private var reviewButton: some View {
|
||||
let n = review.map(findingCount) ?? 0
|
||||
return Button {
|
||||
store.showReview(snap.n)
|
||||
showReview()
|
||||
} label: {
|
||||
Text("review")
|
||||
.font(Fonts.font(11, weight: n > 0 ? 700 : 400))
|
||||
@@ -1408,6 +1601,7 @@ private struct SnapshotRow: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(SmallLinkButtonStyle(color: n > 0 ? Palette.warn : Palette.accent, fill: review?.state == .running ? Palette.accentTint : .clear))
|
||||
.help(review?.state == .running ? "Review in progress; show snapshot \(snap.n)'s review in the Reviews tab" : "Show the review of snapshot \(snap.n) in the Reviews tab")
|
||||
.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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import SwiftUI
|
||||
struct MenuEntry: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let badge: TabBadge
|
||||
let action: () -> Void
|
||||
}
|
||||
|
||||
@@ -312,15 +311,12 @@ private struct MenuRow: View {
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 6) {
|
||||
Text(entry.label)
|
||||
TabBadgeView(badge: entry.badge)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(hover ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 4))
|
||||
.contentShape(Rectangle())
|
||||
Text(entry.label)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(hover ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 4))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { hover = $0 }
|
||||
|
||||
@@ -166,8 +166,7 @@ private func reviewWarnings(_ taskId: String, _ store: Store, _ leave: @escaping
|
||||
var items: [MergeWarning] = []
|
||||
let open: @MainActor () -> Void = {
|
||||
leave {
|
||||
store.focusTask(taskId, .reviews)
|
||||
store.pickReview(latest.n)
|
||||
store.jumpTo(taskId, .review(latest.n))
|
||||
}
|
||||
}
|
||||
let r = reviews.first { $0.snapshotN == latest.n }
|
||||
@@ -233,7 +232,7 @@ private struct MergeContent: View {
|
||||
let leave = model.leave
|
||||
warnings.append(MergeWarning(text: questions == 1 ? "The agent's open question will be dismissed" : "The agent's \(questions) open questions will be dismissed", open: {
|
||||
leave {
|
||||
store.focusTask(taskId, .questions)
|
||||
store.showQuestion(taskId)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2,33 +2,12 @@ import AppKit
|
||||
import ByreAPI
|
||||
import SwiftUI
|
||||
|
||||
enum TabBadge {
|
||||
case none
|
||||
case count(Int, String)
|
||||
}
|
||||
|
||||
struct TabBadgeView: View {
|
||||
let badge: TabBadge
|
||||
|
||||
var body: some View {
|
||||
switch badge {
|
||||
case .none:
|
||||
EmptyView()
|
||||
case .count(let n, let help):
|
||||
Badge(count: n)
|
||||
.help(help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tabLabels: [TaskTab: String] = [
|
||||
.transcript: "Transcript",
|
||||
.board: "Board",
|
||||
.changes: "Changes",
|
||||
.files: "Files",
|
||||
.terminal: "Terminal",
|
||||
.questions: "Questions",
|
||||
.reviews: "Reviews",
|
||||
.workflow: "Workflow",
|
||||
.project: "Project",
|
||||
]
|
||||
@@ -94,30 +73,24 @@ struct MainView: View {
|
||||
struct PaneTabButton: View {
|
||||
let label: String
|
||||
var active = false
|
||||
var attention = false
|
||||
var badge = TabBadge.none
|
||||
let action: () -> Void
|
||||
@Environment(\.paneAccent) private var accent
|
||||
@ViewState private var hover = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 6) {
|
||||
Text(label)
|
||||
TabBadgeView(badge: badge)
|
||||
}
|
||||
.font(Fonts.font(13, weight: attention ? 700 : 400))
|
||||
.foregroundStyle(hover ? Palette.text : attention ? Palette.warn : active ? Palette.text : Palette.muted)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
.background(attention ? Palette.warnTint : .clear)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(active ? accent : attention ? Palette.warn : .clear)
|
||||
.frame(height: 2)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
Text(label)
|
||||
.font(Fonts.font(13))
|
||||
.foregroundStyle(hover || active ? Palette.text : Palette.muted)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(active ? accent : .clear)
|
||||
.frame(height: 2)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.fixedSize()
|
||||
@@ -196,7 +169,6 @@ private struct LeftPane: View {
|
||||
private struct TaskPane: View {
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(Overlays.self) private var overlays
|
||||
@ViewState private var seen: Set<String> = []
|
||||
|
||||
var body: some View {
|
||||
let selected = store.selectedTaskId.flatMap { store.findTask($0) }
|
||||
@@ -219,18 +191,6 @@ private struct TaskPane: View {
|
||||
} action: { frame in
|
||||
overlays.paneFrame = frame
|
||||
}
|
||||
.onAppear {
|
||||
seen = Set(store.inbox.map(\.id))
|
||||
}
|
||||
.onChange(of: store.inbox.map(\.id)) {
|
||||
let fresh = store.inbox.filter { !seen.contains($0.id) }
|
||||
for item in fresh {
|
||||
seen.insert(item.id)
|
||||
}
|
||||
if store.focus.tab == .transcript && !store.paneFocus.drafting && fresh.contains(where: { $0.taskId == store.selectedTaskId }) {
|
||||
store.focusTab(.questions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -247,10 +207,6 @@ private struct TaskPane: View {
|
||||
FilesView()
|
||||
case .terminal:
|
||||
TerminalTab()
|
||||
case .questions:
|
||||
QuestionsView()
|
||||
case .reviews:
|
||||
ReviewsView()
|
||||
case .workflow:
|
||||
WorkflowView()
|
||||
case .project:
|
||||
@@ -381,27 +337,10 @@ private struct TabStrip: View {
|
||||
private struct Info {
|
||||
var order: [TaskTab]
|
||||
var tab: TaskTab
|
||||
var waiting: Int
|
||||
var findings: Int
|
||||
var reviewing: Bool
|
||||
|
||||
func count(_ t: TaskTab) -> Int {
|
||||
t == .questions ? waiting : t == .reviews && !reviewing ? findings : 0
|
||||
}
|
||||
|
||||
func badge(_ t: TaskTab) -> TabBadge {
|
||||
count(t) > 0 ? .count(count(t), t == .questions ? "open questions" :"open findings in the latest review") : .none
|
||||
}
|
||||
}
|
||||
|
||||
private func info() -> Info {
|
||||
let tab = store.focus.tab
|
||||
let waiting = store.inbox.filter { $0.taskId == store.selectedTaskId }.count
|
||||
let transcript = store.transcript
|
||||
let latest = latestReview(transcript)
|
||||
let reviewing = latest?.state == .running
|
||||
let findings = latest.map(findingCount) ?? 0
|
||||
let hasReviews = transcript.contains(where: isReviewEvent)
|
||||
let workflow = store.selectedTaskId.flatMap { store.findTask($0) }?.workflowRun != nil
|
||||
let team = store.selectedTaskId.map { !store.team($0).isEmpty } ?? false
|
||||
let order = taskTabs.filter { t in
|
||||
@@ -410,13 +349,11 @@ private struct TabStrip: View {
|
||||
}
|
||||
switch t {
|
||||
case .board: return team
|
||||
case .questions: return waiting > 0
|
||||
case .reviews: return hasReviews
|
||||
case .workflow: return workflow
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
return Info(order: order, tab: tab, waiting: waiting, findings: findings, reviewing: reviewing)
|
||||
return Info(order: order, tab: tab)
|
||||
}
|
||||
|
||||
private func kept(_ info: Info) -> [TaskTab] {
|
||||
@@ -444,17 +381,16 @@ private struct TabStrip: View {
|
||||
let info = info()
|
||||
let kept = kept(info)
|
||||
let hidden = info.order.filter { !kept.contains($0) }
|
||||
let flagged = hidden.filter { info.count($0) > 0 }
|
||||
HStack(spacing: 4) {
|
||||
ForEach(info.order.filter { kept.contains($0) }, id: \.self) { t in
|
||||
PaneTabButton(label: tabLabels[t]!, active: t == info.tab, attention: info.count(t) > 0, badge: info.badge(t)) {
|
||||
PaneTabButton(label: tabLabels[t]!, active: t == info.tab) {
|
||||
store.focusTab(t)
|
||||
}
|
||||
}
|
||||
if !hidden.isEmpty {
|
||||
MoreButton(badges: flagged.map(info.badge)) {
|
||||
MoreButton {
|
||||
overlays.menu = OverlayMenu(anchor: moreFrame, accent: accent, entries: hidden.map { t in
|
||||
MenuEntry(id: t.rawValue, label: tabLabels[t]!, badge: info.badge(t)) {
|
||||
MenuEntry(id: t.rawValue, label: tabLabels[t]!) {
|
||||
store.focusTab(t)
|
||||
}
|
||||
})
|
||||
@@ -473,14 +409,14 @@ private struct TabStrip: View {
|
||||
.background {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(info.order, id: \.self) { t in
|
||||
PaneTabButton(label: tabLabels[t]!, active: t == info.tab, attention: info.count(t) > 0, badge: info.badge(t)) {}
|
||||
PaneTabButton(label: tabLabels[t]!, active: t == info.tab) {}
|
||||
.onGeometryChange(for: CGFloat.self) { g in
|
||||
g.size.width
|
||||
} action: { w in
|
||||
widths[t] = w
|
||||
}
|
||||
}
|
||||
MoreButton(badges: []) {}
|
||||
MoreButton {}
|
||||
.onGeometryChange(for: CGFloat.self) { g in
|
||||
g.size.width
|
||||
} action: { w in
|
||||
@@ -502,28 +438,18 @@ private struct TabStrip: View {
|
||||
}
|
||||
|
||||
private struct MoreButton: View {
|
||||
let badges: [TabBadge]
|
||||
let action: () -> Void
|
||||
@ViewState private var hover = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 6) {
|
||||
Text("More ▾")
|
||||
ForEach(Array(badges.enumerated()), id: \.offset) { _, b in
|
||||
TabBadgeView(badge: b)
|
||||
}
|
||||
}
|
||||
.font(Fonts.font(13, weight: badges.isEmpty ? 400 : 700))
|
||||
.foregroundStyle(hover ? Palette.text : badges.isEmpty ? Palette.muted : Palette.warn)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
.background(badges.isEmpty ? .clear : Palette.warnTint)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(badges.isEmpty ? .clear : Palette.warn).frame(height: 2)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
Text("More ▾")
|
||||
.font(Fonts.font(13))
|
||||
.foregroundStyle(hover ? Palette.text : Palette.muted)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.fixedSize()
|
||||
|
||||
@@ -9,35 +9,6 @@ private let delegateText = "Use your judgment for this decision and continue wit
|
||||
private let otherStop = "\u{0}other"
|
||||
private let httpURL = Re(#"^https?://"#, caseless: true)
|
||||
|
||||
struct QuestionsView: View {
|
||||
@Environment(Store.self) private var store
|
||||
|
||||
var body: some View {
|
||||
let taskId = store.selectedTaskId
|
||||
let picked = store.question.flatMap { $0.taskId == taskId ? store.questionItem($0.itemId) : nil }
|
||||
let shown = picked ?? store.inbox.filter { $0.taskId == taskId }.sorted { $0.createdAt < $1.createdAt }.first
|
||||
VStack(spacing: 0) {
|
||||
ChangesView(compact: true)
|
||||
if taskId == nil {
|
||||
EmptyText(text: "No task selected.")
|
||||
} else if let shown {
|
||||
let key = "\(shown.id)/\(shown.state.rawValue)/\(shown.responses.count)"
|
||||
ScrollView {
|
||||
QuestionCard(item: shown)
|
||||
.id(key)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.id(shown.id)
|
||||
} else {
|
||||
EmptyText(text: "Nothing waiting on you in this task.")
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
private func numberChoices(_ blocks: [MDBlock], _ next: inout Int) -> [MDBlock] {
|
||||
blocks.map { block in
|
||||
switch block {
|
||||
@@ -179,13 +150,6 @@ private final class CardModel {
|
||||
_ = try await store.api.client.respondInbox(body: .json(.init(responses: [r]))).ok
|
||||
store.drafts?.clearAnswer(item.id)
|
||||
store.paneFocus.setDraft(key, false)
|
||||
if store.question?.itemId == item.id {
|
||||
store.question = nil
|
||||
}
|
||||
let others = store.inbox.contains { $0.taskId == item.taskId && $0.id != item.id }
|
||||
if !others && store.focus.tab == .questions && store.selectedTaskId == item.taskId {
|
||||
store.focusTab(.transcript)
|
||||
}
|
||||
} catch {
|
||||
store.images.restore(key, imgs)
|
||||
overlays.showAlert(errorText(error))
|
||||
@@ -206,7 +170,7 @@ private final class CardModel {
|
||||
}
|
||||
}
|
||||
|
||||
private struct QuestionCard: View {
|
||||
struct QuestionCard: View {
|
||||
let item: InboxItem
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(Overlays.self) private var overlays
|
||||
|
||||
+72
-213
@@ -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))
|
||||
|
||||
@@ -2,7 +2,19 @@ import Foundation
|
||||
import Observation
|
||||
import SwiftData
|
||||
|
||||
let taskTabs: [TaskTab] = [.transcript, .board, .changes, .files, .terminal, .questions, .reviews, .workflow, .project]
|
||||
let taskTabs: [TaskTab] = [.transcript, .board, .changes, .files, .terminal, .workflow, .project]
|
||||
|
||||
enum JumpTarget: Equatable {
|
||||
case summary(Int64)
|
||||
case question(String)
|
||||
case review(Int)
|
||||
}
|
||||
|
||||
struct Jump: Equatable {
|
||||
let taskId: String
|
||||
let target: JumpTarget
|
||||
let n: Int
|
||||
}
|
||||
|
||||
private let historyPage = 1000
|
||||
private let leadPage = 50
|
||||
@@ -36,13 +48,12 @@ final class Store {
|
||||
var inbox: [InboxItem] = []
|
||||
private(set) var selectedTaskId: String?
|
||||
var pinned: Int?
|
||||
var reviewed: Int?
|
||||
var question: (taskId: String, itemId: String)?
|
||||
var connected = false
|
||||
private(set) var focus = Focus(tab: .transcript)
|
||||
var leadSessions: [AgentSession] = []
|
||||
let leadFeed: Feed
|
||||
var jump: (taskId: String, seq: Int64)?
|
||||
var jump: Jump?
|
||||
@ObservationIgnored private var jumps = 0
|
||||
var composerFocus: String?
|
||||
var summaryReads: [String: Int64] = [:]
|
||||
private(set) var leadMore = false
|
||||
@@ -224,28 +235,21 @@ final class Store {
|
||||
pinned = n
|
||||
}
|
||||
|
||||
func pickReview(_ n: Int) {
|
||||
reviewed = n
|
||||
func jumpTo(_ taskId: String, _ target: JumpTarget) {
|
||||
jumps += 1
|
||||
jump = Jump(taskId: taskId, target: target, n: jumps)
|
||||
focusTask(taskId, .transcript)
|
||||
}
|
||||
|
||||
func showReview(_ n: Int) {
|
||||
pickReview(n)
|
||||
focusTab(.reviews)
|
||||
}
|
||||
|
||||
func showQuestion(_ taskId: String, _ itemId: String) {
|
||||
focusTask(taskId, .questions)
|
||||
question = (taskId, itemId)
|
||||
}
|
||||
|
||||
func questionItem(_ itemId: String) -> InboxItem? {
|
||||
if let open = inbox.first(where: { $0.id == itemId }) {
|
||||
return open
|
||||
func showQuestion(_ taskId: String) {
|
||||
guard let item = inbox.filter({ $0.taskId == taskId }).min(by: { $0.createdAt < $1.createdAt }) else {
|
||||
focusTask(taskId, .transcript)
|
||||
return
|
||||
}
|
||||
guard let ev = transcript.last(where: { ($0.kind == "inbox.created" || $0.kind == "inbox.updated") && str($0.payload.value, "id") == itemId }) else {
|
||||
return nil
|
||||
if let member = sessions[taskId]?.first(where: { $0.id == item.agentSessionId })?.member, !member.isEmpty {
|
||||
pickedMembers[taskId] = member
|
||||
}
|
||||
return try! decodePayload(ev.payload, as: InboxItem.self)
|
||||
jumpTo(taskId, .question(item.id))
|
||||
}
|
||||
|
||||
var defaultModel: String {
|
||||
@@ -360,7 +364,6 @@ final class Store {
|
||||
}
|
||||
|
||||
func focusTask(_ taskId: String, _ tab: TaskTab = .transcript) {
|
||||
question = nil
|
||||
spawn("set focus") {
|
||||
try await self.setFocus(taskId: taskId, tab: tab)
|
||||
}
|
||||
@@ -373,7 +376,6 @@ final class Store {
|
||||
}
|
||||
|
||||
func focusTab(_ tab: TaskTab) {
|
||||
question = nil
|
||||
spawn("set focus") {
|
||||
try await self.setFocus(tab: tab)
|
||||
}
|
||||
@@ -386,7 +388,6 @@ final class Store {
|
||||
let name = m?[3] ?? nil
|
||||
let tab = taskTabs.first { $0.rawValue == name }
|
||||
if kind == "task", let id {
|
||||
question = nil
|
||||
if id != focus.taskId || (tab != nil && tab != focus.tab) {
|
||||
spawn("set focus") {
|
||||
try await self.setFocus(taskId: id, tab: tab)
|
||||
@@ -507,8 +508,7 @@ final class Store {
|
||||
let seq = task.summarySeq
|
||||
if let ev = taskFeeds[task.id]?.events.first(where: { $0.seq == seq }) {
|
||||
pickMember(task.id, ev)
|
||||
jump = (task.id, seq)
|
||||
focusTask(task.id, .transcript)
|
||||
jumpTo(task.id, .summary(seq))
|
||||
return
|
||||
}
|
||||
Task {
|
||||
@@ -520,8 +520,7 @@ final class Store {
|
||||
} catch {
|
||||
log.error("load summary event: \(errorText(error), privacy: .public)")
|
||||
}
|
||||
jump = (task.id, seq)
|
||||
focusTask(task.id, .transcript)
|
||||
jumpTo(task.id, .summary(seq))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,7 +538,6 @@ final class Store {
|
||||
_ = taskFeed(taskId)
|
||||
}
|
||||
pinned = nil
|
||||
reviewed = nil
|
||||
dropClosedFeeds()
|
||||
}
|
||||
|
||||
|
||||
@@ -262,7 +262,7 @@ final class SwitcherModel {
|
||||
private func go(_ taskId: String) {
|
||||
close()
|
||||
if store.inbox.contains(where: { $0.taskId == taskId }) {
|
||||
store.focusTask(taskId, .questions)
|
||||
store.showQuestion(taskId)
|
||||
} else if let task = store.findTask(taskId), store.unreadSummary(task) {
|
||||
store.composerFocus = taskId
|
||||
store.openSummary(task)
|
||||
|
||||
@@ -51,6 +51,8 @@ enum RowKind {
|
||||
case turnEnd(error: String?)
|
||||
case note(Note)
|
||||
case review(note: Note, md: [MDBlock])
|
||||
case finding(index: Int)
|
||||
case findingResponse(snapshot: Int, index: Int, response: String, reason: String)
|
||||
case ready(when: Date, md: [MDBlock])
|
||||
case handoff
|
||||
case snapshot(Snapshot)
|
||||
@@ -84,6 +86,7 @@ final class Row: Identifiable {
|
||||
var calls: [Call] = []
|
||||
var summary: Note?
|
||||
var review: Review?
|
||||
var reviewOf: Int?
|
||||
var question: InboxItem?
|
||||
var text = ""
|
||||
var md: [MDBlock] = []
|
||||
@@ -175,6 +178,8 @@ private final class Ctx {
|
||||
var subagents: [String: Subagent] = [:]
|
||||
var reviewers: Set<String> = []
|
||||
var reviewButtons: [Int: Row] = [:]
|
||||
var reviews: [Int: Review] = [:]
|
||||
var reviewRows: [String: [Row]] = [:]
|
||||
var questions: [String: Row] = [:]
|
||||
var run: Run?
|
||||
var held: RowList?
|
||||
@@ -471,6 +476,17 @@ 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.reviewButtons[r.snapshotN]?.review = r
|
||||
ctx.reviews[r.snapshotN] = r
|
||||
for row in ctx.reviewRows[r.id] ?? [] {
|
||||
row.review = r
|
||||
}
|
||||
if ev.kind == "review.finished" {
|
||||
ctx.run = nil
|
||||
for row in renderReview(r, ev.createdAt) {
|
||||
put(row)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ev.kind == "inbox.updated" {
|
||||
let item = try! decodePayload(ev.payload, as: InboxItem.self)
|
||||
@@ -490,6 +506,20 @@ final class TranscriptBuilder {
|
||||
put(row, keepHeld: true)
|
||||
continue
|
||||
}
|
||||
if ev.kind == "agent.tool_call" && sub == nil && str(p, "name") == "mcp__byre__respond_to_finding" {
|
||||
let input = dict(p, "input") ?? [:]
|
||||
let n = int(input, "snapshot") ?? 0
|
||||
let row = Row(.findingResponse(snapshot: n, index: (int(input, "finding") ?? 0) - 1, response: str(input, "response") ?? "", reason: str(input, "reason") ?? ""), taskId: taskId)
|
||||
row.started = ev.createdAt
|
||||
ctx.tools[jsString(p["id"])] = row
|
||||
if let r = ctx.reviews[n], r.state == .done {
|
||||
row.review = r
|
||||
ctx.reviewRows[r.id, default: []].append(row)
|
||||
}
|
||||
ctx.run = nil
|
||||
put(row, keepHeld: true)
|
||||
continue
|
||||
}
|
||||
if ev.kind == "agent.tool_call" {
|
||||
let name = toolLabel(jsString(p["name"]))
|
||||
let row = renderToolCall(p)
|
||||
@@ -605,6 +635,22 @@ final class TranscriptBuilder {
|
||||
return row
|
||||
}
|
||||
|
||||
private func renderReview(_ r: Review, _ when: Date) -> [Row] {
|
||||
let outcome = r.state == .failed ? "failed: \(r.error)" : r.state == .skipped ? "skipped: no changes" : reviewCounts(r)
|
||||
let parts: [NotePart] = [.text("review of snapshot #\(r.snapshotN): \(outcome)")]
|
||||
let fs = findings(r)
|
||||
let head = fs.isEmpty
|
||||
? Row(.note(Note(parts: parts, when: when, style: r.state == .failed ? .error : .plain)), taskId: taskId)
|
||||
: Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: taskId)
|
||||
let rows = [head] + fs.indices.map { Row(.finding(index: $0), taskId: taskId) }
|
||||
for row in rows {
|
||||
row.review = r
|
||||
row.reviewOf = r.snapshotN
|
||||
}
|
||||
ctx.reviewRows[r.id, default: []] += rows
|
||||
return rows
|
||||
}
|
||||
|
||||
private func renderEvent(_ ev: ByreEvent) -> Row? {
|
||||
let p = ev.payload.value
|
||||
let when = ev.createdAt
|
||||
@@ -654,6 +700,9 @@ final class TranscriptBuilder {
|
||||
return Row(.orphan(body), taskId: taskId)
|
||||
}
|
||||
target.result = body
|
||||
if case .findingResponse = target.kind, bool(p, "is_error") {
|
||||
target.hidden = true
|
||||
}
|
||||
return nil
|
||||
|
||||
case "turn.ended":
|
||||
@@ -709,15 +758,6 @@ final class TranscriptBuilder {
|
||||
case "task.merge_title":
|
||||
return note([.text("\(ev.actor) set the merge commit title to \"\(jsString(p["merge_title"]))\"")])
|
||||
|
||||
case "review.finished":
|
||||
let r = try! decodePayload(ev.payload, as: Review.self)
|
||||
let outcome = r.state == .failed ? "failed: \(r.error)" : r.state == .skipped ? "skipped: no changes" : reviewCounts(r)
|
||||
let parts: [NotePart] = [.text("review of snapshot #\(r.snapshotN): \(outcome)")]
|
||||
if findings(r).isEmpty {
|
||||
return note(parts, r.state == .failed ? .error : .plain)
|
||||
}
|
||||
return Row(.review(note: Note(parts: parts, when: when), md: parseMarkdown(str(r.result?.value ?? [:], "summary") ?? "")), taskId: taskId)
|
||||
|
||||
case "task.merge_queued":
|
||||
return note([.text("\(ev.actor) asked to merge; held until the merge in progress in this project is done")])
|
||||
|
||||
@@ -778,7 +818,7 @@ func findings(_ r: Review) -> [JSONDict] {
|
||||
}
|
||||
|
||||
func findingCount(_ r: Review) -> Int {
|
||||
r.state == .done ? findings(r).indices.filter { r.findingStates.additionalProperties[String($0)] == nil }.count : 0
|
||||
r.state == .done ? findings(r).indices.filter { findingOpen(r, $0) }.count : 0
|
||||
}
|
||||
|
||||
func latestReview(_ events: [ByreEvent]) -> Review? {
|
||||
|
||||
Reference in New Issue
Block a user