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
+26 -35
View File
@@ -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
View File
@@ -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)")
}
}
+6 -10
View File
@@ -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 }
+2 -3
View File
@@ -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)
}
}))
}
+25 -99
View File
@@ -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()
+1 -37
View File
@@ -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
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))
+28 -30
View File
@@ -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()
}
+1 -1
View File
@@ -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)
+50 -10
View File
@@ -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? {