Add Merge & continue and Other… to ready cards

This commit is contained in:
Ian Gulliver
2026-10-20 11:01:59 -07:00
parent 25f275e012
commit ef98b1179d
13 changed files with 363 additions and 88 deletions
+104 -18
View File
@@ -323,7 +323,7 @@ final class ChatModel {
if let r = row.review, r.id == latest?.id, findingOpen(r, i) {
open.append(row.id)
}
case .ready(_, _, let d) where row.id == builder.lastReady && active:
case .ready(_, _, let d, _) where row.id == builder.lastReady && active:
ready.append(row.id)
discard = d
default:
@@ -376,7 +376,7 @@ final class ChatModel {
guard let taskId, let task = store.findTask(taskId) else {
return false
}
return task.status != .active || store.inbox.contains(where: { $0.taskId == taskId })
return task.status != .active || store.inbox.contains(where: { $0.taskId == taskId }) || attention().contains { $0.id == "ready" }
}
func showStatus(_ s: ChatStatus) {
@@ -637,6 +637,8 @@ private struct MergeButton: View {
let busy: Bool
let unmerged: Bool
var small = false
var keepOpen: Bool?
var highlight = false
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@@ -644,6 +646,22 @@ private struct MergeButton: View {
let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch"
let held = task.mergeQueuedAt != nil
let intoParent = task.mergingIntoParent
if keepOpen == true {
Button("Merge & continue") {
Task {
await confirmMerge(task.id, store, overlays, keepOpen: true)
}
}
.buttonStyle(SmallLinkButtonStyle(border: highlight ? Palette.accent : Palette.border))
.disabled(busy || held || intoParent || !unmerged)
.help("The agent merges \(target) in, then squashes the work so far into one commit on \(target) and pushes it, keeping the task open; the task's other agents wait until it's done")
} else {
button(target, held, intoParent)
}
}
@ViewBuilder
private func button(_ target: String, _ held: Bool, _ intoParent: Bool) -> some View {
let help = intoParent
? "Waiting for the parent task's agent to merge this subtask"
: held
@@ -653,14 +671,14 @@ private struct MergeButton: View {
: task.parentTaskId != nil
? "Merge this subtask into its parent task's branch, or into \(target) (⌘S)"
: "The agent merges \(target) in, then byre squashes into one commit on \(target) and pushes it to origin; held while another task in the project is merging (⌘S)"
let button = Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : "Merge") {
let button = Button(intoParent ? "Merging into parent" : held ? "Merge held" : task.mergeAfterReview ? "Merge after review" : keepOpen == false ? "Merge & complete" : "Merge") {
Task {
await confirmMerge(task.id, store, overlays)
}
}
Group {
if small {
button.buttonStyle(SmallLinkButtonStyle())
button.buttonStyle(SmallLinkButtonStyle(border: highlight ? Palette.accent : Palette.border))
} else {
button.buttonStyle(WebButtonStyle())
}
@@ -1280,8 +1298,8 @@ struct RowView: View {
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, let discard):
ReadyBubble(when: when, blocks: blocks, discard: discard, taskId: row.taskId, latest: row.id == model.builder.lastReady, unmerged: model.hasUnmerged)
case .ready(let when, let blocks, let discard, let keepOpen):
ReadyBubble(when: when, blocks: blocks, discard: discard, keepOpen: keepOpen, taskId: row.taskId, latest: row.id == model.builder.lastReady, unmerged: model.hasUnmerged)
case .handoff:
DetailsBox(boxed: false) {
@@ -1413,12 +1431,15 @@ private struct ReadyBubble: View {
let when: Date
let blocks: [MDBlock]
let discard: Bool
let keepOpen: Bool
let taskId: String?
let latest: Bool
let unmerged: Bool
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var dismissing = false
@ViewState private var other: String?
@FocusState private var editing: Bool
var body: some View {
let task = taskId.flatMap { store.findTask($0) }
@@ -1435,26 +1456,21 @@ private struct ReadyBubble: View {
MarkdownView(blocks: blocks, ctx: MDContext(taskId: taskId))
if current, let task {
let busy = store.taskBusy(task.id)
otherEntry(task)
.padding(.top, 4)
HStack(spacing: 8) {
Spacer(minLength: 0)
Button("Dismiss") {
dismissing = true
Task {
do {
_ = try await store.api.client.dismissReady(path: .init(taskId: task.id)).ok
} catch {
overlays.showAlert(errorText(error))
}
dismissing = false
}
dismiss(task, then: nil)
}
.buttonStyle(SmallLinkButtonStyle())
.disabled(dismissing)
.help(discard ? "Drop this suggestion; the task stays open" : "Drop this ready call; the task stays open")
if !discard {
MergeButton(task: task, busy: busy, unmerged: unmerged, small: true)
}
DiscardButton(task: task, busy: busy, small: true)
if !discard {
MergeButton(task: task, busy: busy, unmerged: unmerged, small: true, keepOpen: true, highlight: keepOpen)
MergeButton(task: task, busy: busy, unmerged: unmerged, small: true, keepOpen: false, highlight: !keepOpen)
}
}
}
}
@@ -1464,6 +1480,76 @@ private struct ReadyBubble: View {
.background(current ? Palette.warnTint : Palette.warnFaint, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(current ? Palette.warn.opacity(0.55) : Palette.border))
}
@ViewBuilder
private func otherEntry(_ task: ByreTask) -> some View {
if let text = other {
TextEditor(text: Binding(get: { text }, set: { other = $0 }))
.font(Fonts.font(13))
.scrollContentBackground(.hidden)
.writingToolsBehavior(.disabled)
.focused($editing)
.disabled(dismissing)
.onKeyPress(.return, phases: .down) { press in
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
if press.modifiers.contains(.shift) || marked || trimmed(text).isEmpty {
return .ignored
}
dismiss(task, then: text)
return .handled
}
.padding(.horizontal, 5)
.padding(.vertical, 6)
.frame(height: 66)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(alignment: .topLeading) {
if text.isEmpty {
Text("Something else to tell the agent; sending it dismisses this")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.allowsHitTesting(false)
}
}
.overlay(RoundedRectangle(cornerRadius: 6).stroke(editing ? Palette.accent : Palette.border))
.onChange(of: editing) {
if !editing && trimmed(other ?? "").isEmpty {
other = nil
}
}
} else {
Text("Other…")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
.contentShape(Rectangle())
.onTapGesture {
other = ""
DispatchQueue.main.async {
editing = true
}
}
}
}
private func dismiss(_ task: ByreTask, then text: String?) {
dismissing = true
Task {
do {
_ = try await store.api.client.dismissReady(path: .init(taskId: task.id)).ok
if let text {
try await store.messageTask(task.id, text)
other = nil
}
} catch {
overlays.showAlert(errorText(error))
}
dismissing = false
}
}
}
private struct ToolResult: View {