Open merge dialog instantly from pushed merge facts
This commit is contained in:
@@ -36,11 +36,9 @@ final class ChatModel {
|
||||
private(set) var scrollRequest: (seq: Int, id: Int?, anchor: UnitPoint)?
|
||||
private(set) var fetching = false
|
||||
private(set) var older: [ByreEvent]?
|
||||
private(set) var hasUnmerged = false
|
||||
@ObservationIgnored var stuck = true
|
||||
@ObservationIgnored var prepended = false
|
||||
@ObservationIgnored var released: Int?
|
||||
@ObservationIgnored private var unmergedTimer: Task<Void, Never>?
|
||||
@ObservationIgnored private var offs: [() -> Void] = []
|
||||
@ObservationIgnored private var focusStart = true
|
||||
@ObservationIgnored private var visibleSummaries: Set<Int64> = []
|
||||
@@ -81,6 +79,13 @@ final class ChatModel {
|
||||
return nil
|
||||
}
|
||||
|
||||
var hasUnmerged: Bool {
|
||||
guard let f = taskId.flatMap(store.findTask)?.mergeFacts else {
|
||||
return false
|
||||
}
|
||||
return f.unmergedCommits > 0 || f.unmergedFiles > 0
|
||||
}
|
||||
|
||||
private var feed: Feed? {
|
||||
switch kind {
|
||||
case .lead: store.leadFeed
|
||||
@@ -161,9 +166,6 @@ final class ChatModel {
|
||||
guard let feed else {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await loadUnmerged()
|
||||
}
|
||||
appended(feed.events)
|
||||
offs.append(feed.appended.add { [weak self] events in
|
||||
self?.appended(events)
|
||||
@@ -194,39 +196,15 @@ final class ChatModel {
|
||||
NotificationCenter.default.removeObserver(keyObserver)
|
||||
}
|
||||
keyObserver = nil
|
||||
unmergedTimer?.cancel()
|
||||
unmergedTimer = nil
|
||||
builder.stop()
|
||||
store.drafts?.flush()
|
||||
}
|
||||
|
||||
private func appended(_ all: [ByreEvent]) {
|
||||
let events = all.filter(shows)
|
||||
if events.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) {
|
||||
unmergedTimer?.cancel()
|
||||
unmergedTimer = Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(1500))
|
||||
if !Task.isCancelled {
|
||||
await self?.loadUnmerged()
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = builder.append(events)
|
||||
_ = builder.append(all.filter(shows))
|
||||
jump()
|
||||
}
|
||||
|
||||
private func loadUnmerged() async {
|
||||
guard let taskId else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let u = try await store.api.client.getUnmerged(path: .init(taskId: taskId)).ok.body.json
|
||||
hasUnmerged = u.commits > 0 || u.files > 0
|
||||
} catch {
|
||||
log.error("load unmerged changes: \(errorText(error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func takeFocus() -> Bool {
|
||||
let asked = taskId != nil && store.composerFocus == taskId
|
||||
if asked {
|
||||
|
||||
@@ -23,7 +23,7 @@ struct ModalButton {
|
||||
|
||||
struct OverlayModal {
|
||||
let content: AnyView
|
||||
let buttons: [ModalButton]
|
||||
let buttons: @MainActor () -> [ModalButton]
|
||||
let anchor: CGRect?
|
||||
let inPane: Bool
|
||||
let canSubmit: @MainActor () -> Bool
|
||||
@@ -79,7 +79,7 @@ final class Overlays {
|
||||
alerts.append(OverlayAlert(content: AnyView(content)))
|
||||
}
|
||||
|
||||
func showModal(_ content: some View, _ buttons: [ModalButton], anchor: CGRect? = nil, inPane: Bool = false, canSubmit: @escaping @MainActor () -> Bool = { true }) async -> String {
|
||||
func showModal(_ content: some View, _ buttons: @autoclosure @escaping @MainActor () -> [ModalButton], anchor: CGRect? = nil, inPane: Bool = false, canSubmit: @escaping @MainActor () -> Bool = { true }) async -> String {
|
||||
modal?.done("cancel")
|
||||
menu = nil
|
||||
return await withCheckedContinuation { cont in
|
||||
@@ -183,7 +183,7 @@ final class Overlays {
|
||||
}
|
||||
if let modal {
|
||||
if enter && !marked {
|
||||
if modal.canSubmit(), let last = modal.buttons.last {
|
||||
if modal.canSubmit(), let last = modal.buttons().last {
|
||||
answerModal(last.value)
|
||||
}
|
||||
return true
|
||||
@@ -417,7 +417,7 @@ private struct ModalBox: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack(spacing: 8) {
|
||||
Spacer()
|
||||
ForEach(Array(modal.buttons.enumerated()), id: \.offset) { _, b in
|
||||
ForEach(Array(modal.buttons().enumerated()), id: \.offset) { _, b in
|
||||
Button(b.label) {
|
||||
answer(b.value)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import ByreAPI
|
||||
import SwiftUI
|
||||
|
||||
typealias Participant = Components.Schemas.Participant
|
||||
typealias MergeFacts = Components.Schemas.MergeFacts
|
||||
|
||||
@MainActor
|
||||
private var mergeDrafts: [String: String] = [:]
|
||||
@@ -23,22 +24,17 @@ final class MergeModel {
|
||||
let target: String
|
||||
let keepOpen: Bool
|
||||
let parentTitle: String?
|
||||
let coAuthors: [Participant]
|
||||
let original: String
|
||||
var title: String
|
||||
var editing = false
|
||||
var toParent: Bool
|
||||
var reviewItems: [MergeWarning] = []
|
||||
@ObservationIgnored var running = false
|
||||
@ObservationIgnored var leave: Leave = { _ in }
|
||||
@ObservationIgnored private var loads = 0
|
||||
|
||||
init(taskId: String, target: String, keepOpen: Bool, parentTitle: String?, coAuthors: [Participant], title: String) {
|
||||
init(taskId: String, target: String, keepOpen: Bool, parentTitle: String?, title: String) {
|
||||
self.taskId = taskId
|
||||
self.target = target
|
||||
self.keepOpen = keepOpen
|
||||
self.parentTitle = parentTitle
|
||||
self.coAuthors = coAuthors
|
||||
original = title
|
||||
self.title = title
|
||||
toParent = parentTitle != nil
|
||||
@@ -51,26 +47,6 @@ final class MergeModel {
|
||||
var sentTitle: String {
|
||||
editing ? title : original
|
||||
}
|
||||
|
||||
func load(_ store: Store) async {
|
||||
loads += 1
|
||||
let started = loads
|
||||
var items: [MergeWarning]
|
||||
var isRunning = false
|
||||
do {
|
||||
(items, isRunning) = try await reviewWarnings(taskId, store, leave)
|
||||
} catch {
|
||||
items = [MergeWarning(text: "Review status unavailable", detail: errorText(error))]
|
||||
}
|
||||
if started == loads {
|
||||
reviewItems = items
|
||||
running = isRunning
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
loads += 1
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -91,25 +67,14 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays, keepOp
|
||||
return
|
||||
}
|
||||
let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch"
|
||||
let parent = task.workflowStage == nil ? task.parentTaskId.flatMap { store.findTask($0) } : nil
|
||||
let coAuthors: [Participant]
|
||||
do {
|
||||
let client = store.api.client
|
||||
async let unmerged = client.getUnmerged(path: .init(taskId: taskId)).ok.body.json
|
||||
async let people = client.getCoAuthors(path: .init(taskId: taskId)).ok.body.json
|
||||
let (u, p) = try await (unmerged, people)
|
||||
if u.commits == 0 && u.files == 0 {
|
||||
overlays.showAlert("Nothing to merge: no commits or uncommitted files differ from \(target)")
|
||||
return
|
||||
}
|
||||
coAuthors = p
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
if task.mergeFacts.unmergedCommits == 0 && task.mergeFacts.unmergedFiles == 0 {
|
||||
overlays.showAlert("Nothing to merge: no commits or uncommitted files differ from \(target)")
|
||||
return
|
||||
}
|
||||
let parent = task.workflowStage == nil ? task.parentTaskId.flatMap { store.findTask($0) } : nil
|
||||
let title = mergeDrafts[taskId] ?? task.mergeTitle
|
||||
mergeDrafts[taskId] = nil
|
||||
let model = MergeModel(taskId: taskId, target: target, keepOpen: keepOpen, parentTitle: !keepOpen && 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, title: title)
|
||||
model.leave = { [weak model] go in
|
||||
if let model {
|
||||
mergeDrafts[taskId] = model.sentTitle
|
||||
@@ -117,92 +82,58 @@ func confirmMerge(_ taskId: String, _ store: Store, _ overlays: Overlays, keepOp
|
||||
overlays.answerModal("cancel")
|
||||
go()
|
||||
}
|
||||
let off = store.events.add { ev in
|
||||
if ev.taskId == taskId && (ev.kind == "snapshot.created" || isReviewEvent(ev)) {
|
||||
Task {
|
||||
await model.load(store)
|
||||
}
|
||||
}
|
||||
}
|
||||
defer {
|
||||
model.stop()
|
||||
off()
|
||||
}
|
||||
await model.load(store)
|
||||
let content = MergeContent(model: model)
|
||||
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, 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)
|
||||
}
|
||||
let choice = await overlays.showModal(content, keepOpen
|
||||
? [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge & continue", value: "now", primary: true)]
|
||||
: store.findTask(taskId)?.mergeFacts.reviewRunning == true
|
||||
? [
|
||||
ModalButton(label: "Cancel", value: "cancel"),
|
||||
ModalButton(label: "Merge & complete now", value: "now"),
|
||||
ModalButton(label: "Merge & complete after review", value: "after", primary: true, help: "Merge once the running review finishes with no findings"),
|
||||
]
|
||||
: [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge & complete", value: "now", primary: true)],
|
||||
inPane: true, canSubmit: { model.valid })
|
||||
if choice != "now" && choice != "after" {
|
||||
return
|
||||
}
|
||||
if !model.running {
|
||||
if await overlays.showModal(content, [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Merge & complete", value: "ok", primary: true)], inPane: true, canSubmit: { model.valid }) == "ok" {
|
||||
await merge(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
let choice = await overlays.showModal(content, [
|
||||
ModalButton(label: "Cancel", value: "cancel"),
|
||||
ModalButton(label: "Merge & complete now", value: "now"),
|
||||
ModalButton(label: "Merge & complete after review", value: "after", primary: true, help: "Merge once the running review finishes with no findings"),
|
||||
], inPane: true, canSubmit: { model.valid })
|
||||
if choice == "now" || choice == "after" {
|
||||
await merge(choice == "after")
|
||||
let afterReview = choice == "after"
|
||||
let body: Operations.MergeTask.Input.Body.JsonPayload = model.toParent
|
||||
? .init(target: .parent, 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))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func reviewWarnings(_ taskId: String, _ store: Store, _ leave: @escaping Leave) async throws -> ([MergeWarning], Bool) {
|
||||
let client = store.api.client
|
||||
async let snapsCall = client.listSnapshots(path: .init(taskId: taskId)).ok.body.json
|
||||
async let reviewsCall = client.listReviews(path: .init(taskId: taskId)).ok.body.json
|
||||
let (snaps, reviews) = try await (snapsCall, reviewsCall)
|
||||
guard let latest = snaps.last(where: { ![.answer, .fork, .askHuman].contains($0.trigger) }) else {
|
||||
return ([MergeWarning(text: "These changes haven't been reviewed")], false)
|
||||
private func reviewWarnings(_ taskId: String, _ f: MergeFacts, _ store: Store, _ leave: @escaping Leave) -> [MergeWarning] {
|
||||
if f.reviewSnapshot == 0 {
|
||||
return [MergeWarning(text: "These changes haven't been reviewed")]
|
||||
}
|
||||
var items: [MergeWarning] = []
|
||||
let open: @MainActor () -> Void = {
|
||||
leave {
|
||||
store.jumpTo(taskId, .review(latest.n))
|
||||
store.jumpTo(taskId, .review(f.reviewSnapshot))
|
||||
}
|
||||
}
|
||||
let rs = reviews.filter { $0.snapshotN == latest.n }
|
||||
for r in rs {
|
||||
let found = unignored(r)
|
||||
switch r.state {
|
||||
case .running:
|
||||
items.append(MergeWarning(text: "The \(r.stage) review of the latest snapshot is still running", open: open))
|
||||
case .failed:
|
||||
items.append(MergeWarning(text: "The \(r.stage) review of the latest snapshot failed", open: open))
|
||||
default:
|
||||
if !found.isEmpty {
|
||||
items.append(MergeWarning(text: "The \(r.stage) review of the latest snapshot found \(severityCounts(found))", open: open))
|
||||
}
|
||||
}
|
||||
}
|
||||
if rs.isEmpty {
|
||||
if f.reviewCount == 0 {
|
||||
items.append(MergeWarning(text: "The latest snapshot hasn't been reviewed", open: open))
|
||||
}
|
||||
let changes = try await client.getTaskChanges(path: .init(taskId: taskId), query: .init(since: latest.commitSha)).ok.body.json
|
||||
if !changes.files.isEmpty {
|
||||
if f.reviewRunning {
|
||||
items.append(MergeWarning(text: "The review of the latest snapshot is still running", open: open))
|
||||
}
|
||||
if f.reviewFailed {
|
||||
items.append(MergeWarning(text: "The review of the latest snapshot failed", open: open))
|
||||
}
|
||||
if f.reviewFindings > 0 {
|
||||
items.append(MergeWarning(text: "The review of the latest snapshot has \(f.reviewFindings) open finding\(f.reviewFindings == 1 ? "" : "s")", open: open))
|
||||
}
|
||||
if f.unreviewedChanges {
|
||||
items.append(MergeWarning(text: "Changes since the last snapshot haven't been reviewed"))
|
||||
}
|
||||
return (items, rs.contains { $0.state == .running })
|
||||
}
|
||||
|
||||
private func unignored(_ r: Review) -> [JSONDict] {
|
||||
r.state == .done ? findings(r).enumerated().filter { r.findingStates.additionalProperties[String($0.offset)] != .ignored }.map(\.element) : []
|
||||
return items
|
||||
}
|
||||
|
||||
private func coAuthorsLabel(_ coAuthors: [Participant]) -> String {
|
||||
@@ -234,11 +165,13 @@ private struct MergeContent: View {
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
let questions = store.inbox.filter { $0.taskId == model.taskId }.count
|
||||
var warnings = model.reviewItems
|
||||
let taskId = model.taskId
|
||||
let leave = model.leave
|
||||
let facts = store.findTask(taskId)?.mergeFacts
|
||||
let coAuthors = facts?.participants.filter { $0.login != store.me?.login } ?? []
|
||||
let questions = store.inbox.filter { $0.taskId == taskId }.count
|
||||
var warnings = facts.map { reviewWarnings(taskId, $0, store, leave) } ?? []
|
||||
if questions > 0 {
|
||||
let taskId = model.taskId
|
||||
let leave = model.leave
|
||||
warnings.append(MergeWarning(text: questions == 1 ? "The agent's open question will be dismissed" : "The agent's \(questions) open questions will be dismissed", open: {
|
||||
leave {
|
||||
store.showQuestion(taskId)
|
||||
@@ -269,12 +202,12 @@ private struct MergeContent: View {
|
||||
.foregroundStyle(Palette.warn)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
if !model.toParent && !model.coAuthors.isEmpty {
|
||||
if !model.toParent && !coAuthors.isEmpty {
|
||||
DetailsBox(boxed: false) {
|
||||
Text(coAuthorsLabel(model.coAuthors))
|
||||
Text(coAuthorsLabel(coAuthors))
|
||||
.foregroundStyle(Palette.muted)
|
||||
} content: {
|
||||
ModalText(text: mergeTrailers(model.coAuthors))
|
||||
ModalText(text: mergeTrailers(coAuthors))
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.bottom, 16)
|
||||
|
||||
@@ -702,6 +702,10 @@ final class Store {
|
||||
try await self.loadTasks(projectId)
|
||||
}
|
||||
}
|
||||
case "task.merge_facts":
|
||||
if let projectId = ev.projectId, let taskId = ev.taskId, let i = tasks[projectId]?.firstIndex(where: { $0.id == taskId }) {
|
||||
tasks[projectId]![i].mergeFacts = try! decodePayload(ev.payload, as: MergeFacts.self)
|
||||
}
|
||||
case "task.merge_stopped", "task.merge_after_review_dropped", "task.workflow_finished":
|
||||
notifyTask(self, ev)
|
||||
case "turn.ended":
|
||||
|
||||
Reference in New Issue
Block a user