2026-10-17 09:33:27 -07:00
import ByreAPI
2026-10-17 08:24:22 -07:00
import SwiftUI
typealias Participant = Components . Schemas . Participant
2026-10-21 11:48:21 -07:00
typealias MergeFacts = Components . Schemas . MergeFacts
2026-10-17 08:24:22 -07:00
@ MainActor
private var mergeDrafts : [ String : String ] = [:]
private let maxTitle = 72
typealias Leave = @ MainActor (@ escaping @ MainActor () -> Void ) -> Void
struct MergeWarning {
let text : String
var open : (@ MainActor () -> Void )?
var detail = ""
}
@ MainActor
@ Observable
final class MergeModel {
let taskId : String
let target : String
2026-10-20 11:01:59 -07:00
let keepOpen : Bool
2026-10-17 08:24:22 -07:00
let parentTitle : String ?
let original : String
var title : String
var editing = false
var toParent : Bool
@ ObservationIgnored var leave : Leave = { _ in }
2026-10-21 11:48:21 -07:00
init ( taskId : String , target : String , keepOpen : Bool , parentTitle : String ?, title : String ) {
2026-10-17 08:24:22 -07:00
self . taskId = taskId
self . target = target
2026-10-20 11:01:59 -07:00
self . keepOpen = keepOpen
2026-10-17 08:24:22 -07:00
self . parentTitle = parentTitle
original = title
self . title = title
toParent = parentTitle != nil
}
var valid : Bool {
toParent || ! trimmed ( title ). isEmpty
}
var sentTitle : String {
editing ? title : original
}
}
@ MainActor
2026-10-20 11:01:59 -07:00
func confirmMerge ( _ taskId : String , _ store : Store , _ overlays : Overlays , keepOpen : Bool = false ) async {
2026-10-17 08:24:22 -07:00
guard let task = store . findTask ( taskId ), task . status == . active else {
return
}
2026-10-18 08:48:57 -07:00
if store . taskBusy ( taskId ) {
2026-10-17 08:24:22 -07:00
overlays . showAlert ( "Agent is mid-turn; merge when it's idle" )
return
}
if task . mergeQueuedAt != nil {
overlays . showAlert ( "Merge already held behind another merge in this project" )
return
}
if task . mergingIntoParent {
overlays . showAlert ( "Already waiting for the parent task's agent to merge this subtask" )
return
}
let target = store . projects . first { $0 . id == task . projectId }?. defaultBranch ?? "the default branch"
2026-10-21 11:48:21 -07:00
if task . mergeFacts . unmergedCommits == 0 && task . mergeFacts . unmergedFiles == 0 {
overlays . showAlert ( "Nothing to merge: no commits or uncommitted files differ from \( target ) " )
2026-10-17 08:24:22 -07:00
return
}
2026-10-21 11:48:21 -07:00
let parent = task . workflowStage == nil ? task . parentTaskId . flatMap { store . findTask ( $0 ) } : nil
2026-10-17 08:24:22 -07:00
let title = mergeDrafts [ taskId ] ?? task . mergeTitle
mergeDrafts [ taskId ] = nil
2026-10-21 11:48:21 -07:00
let model = MergeModel ( taskId : taskId , target : target , keepOpen : keepOpen , parentTitle : ! keepOpen && parent ?. status == . active ? parent ?. name : nil , title : title )
2026-10-17 08:24:22 -07:00
model . leave = { [ weak model ] go in
if let model {
mergeDrafts [ taskId ] = model . sentTitle
}
overlays . answerModal ( "cancel" )
go ()
}
let content = MergeContent ( model : model )
2026-10-21 11:48:21 -07:00
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" {
2026-10-17 08:24:22 -07:00
return
}
2026-10-21 11:48:21 -07:00
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 ))
2026-10-17 08:24:22 -07:00
}
}
@ MainActor
2026-10-21 11:48:21 -07:00
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" )]
2026-10-17 08:24:22 -07:00
}
var items : [ MergeWarning ] = []
let open : @ MainActor () -> Void = {
leave {
2026-10-21 11:48:21 -07:00
store . jumpTo ( taskId , . review ( f . reviewSnapshot ))
2026-10-17 08:24:22 -07:00
}
}
2026-10-21 11:48:21 -07:00
if f . reviewCount == 0 {
2026-10-17 08:24:22 -07:00
items . append ( MergeWarning ( text : "The latest snapshot hasn't been reviewed" , open : open ))
}
2026-10-21 11:48:21 -07:00
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 {
2026-10-17 08:24:22 -07:00
items . append ( MergeWarning ( text : "Changes since the last snapshot haven't been reviewed" ))
}
2026-10-21 11:48:21 -07:00
return items
2026-10-17 08:24:22 -07:00
}
private func coAuthorsLabel ( _ coAuthors : [ Participant ]) -> String {
let missing = coAuthors . filter { $0 . gitName . isEmpty || $0 . gitEmail . isEmpty }. count
return " \( coAuthors . count ) co-author \( coAuthors . count == 1 ? "" : "s" )\( missing > 0 ? " ( \( missing ) without git identity)" : "" ) "
}
private func mergeTrailers ( _ coAuthors : [ Participant ]) -> String {
var lines : [ String ] = []
let credited = coAuthors . filter { ! $0 . gitName . isEmpty && ! $0 . gitEmail . isEmpty }
let missing = coAuthors . filter { $0 . gitName . isEmpty || $0 . gitEmail . isEmpty }
if ! credited . isEmpty {
lines . append ( "Co-authored-by:" )
lines += credited . map { " \( $0 . gitName ) < \( $0 . gitEmail ) >" }
}
if ! missing . isEmpty {
if ! lines . isEmpty {
lines . append ( "" )
}
lines . append ( "No git identity, so no trailer:" )
lines += missing . map { " \( $0 . displayName ) ( \( $0 . login ) )" }
}
return lines . joined ( separator : " \n " )
}
private struct MergeContent : View {
@ Bindable var model : MergeModel
@ Environment ( Store . self ) private var store
@ FocusState private var focused : Bool
var body : some View {
2026-10-21 11:48:21 -07:00
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 ) } ?? []
2026-10-17 08:24:22 -07:00
if questions > 0 {
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 {
2026-10-19 09:14:21 -07:00
store . showQuestion ( taskId )
2026-10-17 08:24:22 -07:00
}
}))
}
return VStack ( alignment : . leading , spacing : 0 ) {
if let parentTitle = model . parentTitle {
VStack ( alignment : . leading , spacing : 8 ) {
option ( true , "Into \( parentTitle ) " , "Its agent merges this subtask's branch into the parent's branch; the subtask closes once it has." )
option ( false , "Into \( model . target ) " , "Squashed into one commit on the default branch and pushed, like any task." )
}
. padding (. bottom , 16 )
}
if ! model . toParent {
2026-10-20 11:01:59 -07:00
Text ( model . keepOpen ? "Merge the work so far into \( model . target ) , keeping the task open, as:" : "Merge into \( model . target ) as:" )
2026-10-17 08:24:22 -07:00
. foregroundStyle ( Palette . muted )
. padding (. bottom , 4 )
titleRow
. padding (. bottom , 16 )
}
if ! warnings . isEmpty {
VStack ( alignment : . leading , spacing : 2 ) {
ForEach ( Array ( warnings . enumerated ()), id : \ . offset ) { _ , w in
warningRow ( w )
}
}
. foregroundStyle ( Palette . warn )
. padding (. bottom , 16 )
}
2026-10-21 11:48:21 -07:00
if ! model . toParent && ! coAuthors . isEmpty {
2026-10-17 08:24:22 -07:00
DetailsBox ( boxed : false ) {
2026-10-21 11:48:21 -07:00
Text ( coAuthorsLabel ( coAuthors ))
2026-10-17 08:24:22 -07:00
. foregroundStyle ( Palette . muted )
} content : {
2026-10-21 11:48:21 -07:00
ModalText ( text : mergeTrailers ( coAuthors ))
2026-10-17 08:24:22 -07:00
. padding (. top , 8 )
}
. padding (. bottom , 16 )
}
}
. frame ( maxWidth : . infinity , alignment : . leading )
}
@ ViewBuilder
private var titleRow : some View {
if model . editing {
TextField ( "" , text : Binding ( get : { model . title }, set : { model . title = String ( $0 . prefix ( maxTitle )) }))
. modifier ( FieldStyle ( focused : focused ))
. focused ( $ focused )
. accessibilityLabel ( "Commit title" )
. onAppear {
focused = true
}
} else {
2026-10-19 17:49:35 -07:00
HStack ( alignment : . firstTextBaseline , spacing : 8 ) {
2026-10-17 08:24:22 -07:00
Text ( model . title )
. font ( Fonts . font ( 13 , weight : 700 ))
. textSelection (. enabled )
. fixedSize ( horizontal : false , vertical : true )
2026-10-19 17:49:35 -07:00
. frame ( maxWidth : . infinity , alignment : . leading )
2026-10-17 08:24:22 -07:00
Button ( "✎" ) {
model . editing = true
}
. buttonStyle (. plain )
. foregroundStyle ( Palette . muted )
. padding (. horizontal , 6 )
. help ( "Edit the commit title" )
. accessibilityLabel ( "Edit the commit title" )
}
}
}
private func option ( _ value : Bool , _ label : String , _ hint : String ) -> some View {
Button {
model . toParent = value
} label : {
HStack ( alignment : . firstTextBaseline , spacing : 8 ) {
Image ( systemName : model . toParent == value ? "largecircle.fill.circle" : "circle" )
. foregroundStyle ( model . toParent == value ? Palette . accent : Palette . muted )
VStack ( alignment : . leading , spacing : 0 ) {
Text ( label )
Text ( hint )
. font ( Fonts . font ( 12 ))
. foregroundStyle ( Palette . muted )
. fixedSize ( horizontal : false , vertical : true )
}
}
. contentShape ( Rectangle ())
}
. buttonStyle (. plain )
}
@ ViewBuilder
private func warningRow ( _ w : MergeWarning ) -> some View {
if let open = w . open {
HStack ( alignment : . firstTextBaseline , spacing : 0 ) {
Text ( "⚠ " )
Button ( action : open ) {
Text ( w . text )
. underline ()
. multilineTextAlignment (. leading )
. fixedSize ( horizontal : false , vertical : true )
}
. buttonStyle (. plain )
}
} else {
Text ( "⚠ \( w . text ) " )
. fixedSize ( horizontal : false , vertical : true )
. help ( w . detail )
}
}
}
@ MainActor
2026-10-20 22:40:48 -07:00
func confirmDiscard ( _ task : ByreTask , _ store : Store , _ overlays : Overlays ) async {
if ! ( await overlays . showConfirm ([ "Discard \" \( task . name ) \" ?" ], action : "Discard" , danger : true , inPane : true )) {
2026-10-17 08:24:22 -07:00
return
}
do {
_ = try await store . api . client . discardTask ( path : . init ( taskId : task . id )). accepted
try await store . setFocus ( taskId : . some ( nil ))
} catch {
overlays . showAlert ( errorText ( error ))
}
}