Let the human comment on each decision in the ready-for-merge card

This commit is contained in:
Ian Gulliver
2026-10-21 16:05:26 -07:00
parent 8202e9fc9f
commit ccb08ab6ec
7 changed files with 166 additions and 45 deletions
+95 -12
View File
@@ -1396,12 +1396,25 @@ private struct ReadyBubble: View {
var body: some View {
let task = taskId.flatMap { store.findTask($0) }
let current = latest && task?.status == .active
AttentionCard(open: current) {
var ctx = MDContext(taskId: taskId)
if current, let task {
let busy = dismissing
let focus = $focus
let base = ctx
func discuss(_ item: MDItem) -> AnyView {
var inner = base
inner.discuss = discuss
return AnyView(DiscussItem(item: item, key: discussKey(task, item.index), busy: busy, focus: focus, ctx: inner, send: { send(task) }))
}
ctx.discuss = discuss
}
return AttentionCard(open: current) {
VStack(alignment: .leading, spacing: 4) {
AttentionHeader(label: discard ? "suggests discarding" : "ready for merge", when: when, open: current)
MarkdownView(blocks: blocks, ctx: MDContext(taskId: taskId))
MarkdownView(blocks: blocks, ctx: ctx)
if current, let task {
let busy = store.taskBusy(task.id)
let commented = comments(task).count
AttentionReply(key: draftKey(task), placeholder: "Something else to tell the agent; sending it dismisses this", busy: dismissing, focus: $focus, send: { send(task) }) {
Button("Dismiss") {
dismiss(task, then: nil)
@@ -1410,7 +1423,13 @@ private struct ReadyBubble: View {
.help(discard ? "Drop this suggestion; the task stays open" : "Drop this ready call; the task stays open")
DiscardButton(task: task, busy: busy)
} trailing: {
if !discard {
if commented > 0 {
Button(commented == 1 ? "Send 1 comment" : "Send \(commented) comments") {
send(task)
}
.buttonStyle(WebButtonStyle(primary: true))
.help("Send your comments to the agent in one message; sending dismisses this")
} else if !discard {
MergeButton(task: task, busy: busy, unmerged: unmerged, keepOpen: true, primary: task.readyMerge == ._continue)
MergeButton(task: task, busy: busy, unmerged: unmerged, keepOpen: false, primary: task.readyMerge != ._continue)
}
@@ -1425,10 +1444,40 @@ private struct ReadyBubble: View {
"ready:\(task.id):\(when.timeIntervalSince1970)"
}
private func discussKey(_ task: ByreTask, _ i: Int) -> String {
"\(draftKey(task)):discuss:\(i)"
}
private func keys(_ task: ByreTask) -> [String] {
discussItems(blocks).map { discussKey(task, $0.index) } + [draftKey(task)]
}
private func comments(_ task: ByreTask) -> [(MDItem, String)] {
discussItems(blocks).compactMap { item in
let key = discussKey(task, item.index)
let text = trimmed(store.drafts?.textAnswer(key) ?? "")
if text.isEmpty && !store.images.has(key) {
return nil
}
return (item, text)
}
}
private func message(_ task: ByreTask) -> String {
let other = trimmed(store.drafts?.textAnswer(draftKey(task)) ?? "")
let parts = comments(task).map { item, text in
item.quote.components(separatedBy: "\n").map { "> \($0)" }.joined(separator: "\n") + (text.isEmpty ? "" : "\n\n\(text)")
}
if parts.isEmpty {
return other
}
let intro = discard ? "Comments on your suggestion to discard:" : "Comments on your ready-for-merge overview:"
return ([intro] + parts + (other.isEmpty ? [] : [other])).joined(separator: "\n\n")
}
private func send(_ task: ByreTask) {
let key = draftKey(task)
let text = store.drafts?.textAnswer(key) ?? ""
if trimmed(text).isEmpty && !store.images.has(key) {
let text = message(task)
if text.isEmpty && !keys(task).contains(where: { store.images.has($0) }) {
return
}
dismiss(task, then: text)
@@ -1436,19 +1485,23 @@ private struct ReadyBubble: View {
private func dismiss(_ task: ByreTask, then text: String?) {
dismissing = true
let key = draftKey(task)
let imgs = store.images.take(key)
let keys = keys(task)
let imgs = keys.map { store.images.take($0) }
Task {
do {
let ids = text == nil ? nil : try await uploadImages(store.api, imgs)
let ids = text == nil ? nil : try await uploadImages(store.api, imgs.flatMap { $0 })
_ = try await store.api.client.dismissReady(path: .init(taskId: task.id)).ok
if let text {
try await store.messageTask(task.id, text, images: ids)
}
store.drafts?.dropTextAnswer(key)
store.paneFocus.setDraft(key, false)
for key in keys {
store.drafts?.dropTextAnswer(key)
store.paneFocus.setDraft(key, false)
}
} catch {
store.images.restore(key, imgs)
for (key, imgs) in zip(keys, imgs) {
store.images.restore(key, imgs)
}
overlays.showAlert(errorText(error))
}
dismissing = false
@@ -1456,6 +1509,36 @@ private struct ReadyBubble: View {
}
}
private struct DiscussItem: View {
let item: MDItem
let key: String
let busy: Bool
var focus: FocusState<String?>.Binding
let ctx: MDContext
let send: () -> Void
@Environment(Store.self) private var store
var body: some View {
let open = otherOpen(store, key)
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
ItemBody(item: item, ctx: ctx)
if !open {
Button("Discuss") {
store.drafts?.setTextAnswer(key, "")
}
.buttonStyle(SmallLinkButtonStyle())
.help("Comment on this; your comments go to the agent together")
.disabled(busy)
}
}
if open {
OtherEntry(key: key, placeholder: "Your comment on this", busy: busy, focus: focus, send: send)
}
}
}
}
private struct ToolResult: View {
let result: String?
let unfinished: Bool