Move questions and reviews into the transcript; agents answer findings
This commit is contained in:
+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)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user