Add Merge & continue and Other… to ready cards
This commit is contained in:
+104
-18
@@ -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 {
|
||||
|
||||
@@ -21,6 +21,7 @@ struct MergeWarning {
|
||||
final class MergeModel {
|
||||
let taskId: String
|
||||
let target: String
|
||||
let keepOpen: Bool
|
||||
let parentTitle: String?
|
||||
let coAuthors: [Participant]
|
||||
let original: String
|
||||
@@ -32,9 +33,10 @@ final class MergeModel {
|
||||
@ObservationIgnored var leave: Leave = { _ in }
|
||||
@ObservationIgnored private var loads = 0
|
||||
|
||||
init(taskId: String, target: String, parentTitle: String?, coAuthors: [Participant], title: String) {
|
||||
init(taskId: String, target: String, keepOpen: Bool, parentTitle: String?, coAuthors: [Participant], title: String) {
|
||||
self.taskId = taskId
|
||||
self.target = target
|
||||
self.keepOpen = keepOpen
|
||||
self.parentTitle = parentTitle
|
||||
self.coAuthors = coAuthors
|
||||
original = title
|
||||
@@ -72,7 +74,7 @@ final class MergeModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async {
|
||||
func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays, keepOpen: Bool = false) async {
|
||||
guard let task = store.findTask(taskId), task.status == .active else {
|
||||
return
|
||||
}
|
||||
@@ -107,7 +109,7 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async
|
||||
}
|
||||
let title = mergeDrafts[taskId] ?? task.mergeTitle
|
||||
mergeDrafts[taskId] = nil
|
||||
let model = MergeModel(taskId: taskId, target: target, parentTitle: parent?.status == .active ? parent?.name : nil, coAuthors: coAuthors, title: title)
|
||||
let model = MergeModel(taskId: taskId, target: target, keepOpen: keepOpen, parentTitle: !keepOpen && parent?.status == .active ? parent?.name : nil, coAuthors: coAuthors, title: title)
|
||||
model.leave = { [weak model] go in
|
||||
if let model {
|
||||
mergeDrafts[taskId] = model.sentTitle
|
||||
@@ -131,13 +133,19 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays) async
|
||||
let merge = { (afterReview: Bool) in
|
||||
let body: Operations.MergeTask.Input.Body.JsonPayload = model.toParent
|
||||
? .init(target: .parent, afterReview: afterReview ? true : nil)
|
||||
: .init(mergeTitle: model.sentTitle, afterReview: afterReview ? true : nil)
|
||||
: .init(mergeTitle: model.sentTitle, afterReview: afterReview ? true : nil, keepOpen: keepOpen ? true : nil)
|
||||
do {
|
||||
_ = try await store.api.client.mergeTask(path: .init(taskId: taskId), body: .json(body)).accepted
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
}
|
||||
if keepOpen {
|
||||
if await overlays.showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge & continue", value: "ok", primary: true)], inPane: true, canSubmit: { model.valid }) == "ok" {
|
||||
await merge(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !model.running {
|
||||
if await overlays.showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge", value: "ok", primary: true)], inPane: true, canSubmit: { model.valid }) == "ok" {
|
||||
await merge(false)
|
||||
@@ -245,7 +253,7 @@ private struct MergeContent: View {
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
if !model.toParent {
|
||||
Text("Merge into \(model.target) as:")
|
||||
Text(model.keepOpen ? "Merge the work so far into \(model.target), keeping the task open, as:" : "Merge into \(model.target) as:")
|
||||
.foregroundStyle(Palette.muted)
|
||||
.padding(.bottom, 4)
|
||||
titleRow
|
||||
|
||||
@@ -53,7 +53,7 @@ enum RowKind {
|
||||
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], discard: Bool)
|
||||
case ready(when: Date, md: [MDBlock], discard: Bool, keepOpen: Bool)
|
||||
case handoff
|
||||
case snapshot(Snapshot)
|
||||
case question
|
||||
@@ -822,10 +822,10 @@ final class TranscriptBuilder {
|
||||
return note([.text("\(ev.actor) set the task summary")])
|
||||
|
||||
case "task.ready":
|
||||
return Row(.ready(when: when, md: parseMarkdown(jsString(p["overview"])), discard: false), taskId: rowTask)
|
||||
return Row(.ready(when: when, md: parseMarkdown(jsString(p["overview"])), discard: false, keepOpen: str(p, "merge") == "continue"), taskId: rowTask)
|
||||
|
||||
case "task.discard_suggested":
|
||||
return Row(.ready(when: when, md: parseMarkdown(jsString(p["reason"])), discard: true), taskId: rowTask)
|
||||
return Row(.ready(when: when, md: parseMarkdown(jsString(p["reason"])), discard: true, keepOpen: false), taskId: rowTask)
|
||||
|
||||
case "task.ready_cleared":
|
||||
return note([.text(ev.actor == "agent" ? "the agent withdrew its ready call" : "\(ev.actor) dismissed the ready call")])
|
||||
|
||||
Reference in New Issue
Block a user