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
+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)")
}
}