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
@ 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
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 , parentTitle : String ?, coAuthors : [ Participant ], title : String ) {
self . taskId = taskId
self . target = target
self . parentTitle = parentTitle
self . coAuthors = coAuthors
original = title
self . title = title
toParent = parentTitle != nil
}
var valid : Bool {
toParent || ! trimmed ( title ). isEmpty
}
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
func confirmMerge ( _ taskId : String , _ store : Store , _ overlays : Overlays ) async {
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"
let parent = task . parentTaskId . flatMap { store . findTask ( $0 ) }
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 ))
return
}
let title = mergeDrafts [ taskId ] ?? task . mergeTitle
mergeDrafts [ taskId ] = nil
let model = MergeModel ( taskId : taskId , target : target , parentTitle : parent ?. status == . active ? parent ?. title : nil , coAuthors : coAuthors , title : title )
model . leave = { [ weak model ] go in
if let model {
mergeDrafts [ taskId ] = model . sentTitle
}
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 )
do {
_ = try await store . api . client . mergeTask ( path : . init ( taskId : taskId ), body : . json ( body )). accepted
} catch {
overlays . showAlert ( errorText ( error ))
}
}
if ! model . running {
2026-10-17 08:51:59 -07:00
if await overlays . showModal ( content , [ ModalButton ( label : "Cancel" , value : "cancel" ), ModalButton ( label : "Merge" , value : "ok" , primary : true )], inPane : true , canSubmit : { model . valid }) == "ok" {
2026-10-17 08:24:22 -07:00
await merge ( false )
}
return
}
let choice = await overlays . showModal ( content , [
ModalButton ( label : "Cancel" , value : "cancel" ),
ModalButton ( label : "Merge now" , value : "now" ),
ModalButton ( label : "After review" , value : "after" , primary : true , help : "Merge once the running review finishes with no findings" ),
2026-10-17 08:51:59 -07:00
], inPane : true , canSubmit : { model . valid })
2026-10-17 08:24:22 -07:00
if choice == "now" || choice == "after" {
await merge ( choice == "after" )
}
}
@ 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 else {
return ([ MergeWarning ( text : "These changes haven't been reviewed" )], false )
}
var items : [ MergeWarning ] = []
let open : @ MainActor () -> Void = {
leave {
store . focusTask ( taskId , . reviews )
store . pickReview ( latest . n )
}
}
let r = reviews . first { $0 . snapshotN == latest . n }
if let r {
let found = unignored ( r )
switch r . state {
case . running :
items . append ( MergeWarning ( text : "Review of the latest snapshot is still running" , open : open ))
case . failed :
items . append ( MergeWarning ( text : "Review of the latest snapshot failed" , open : open ))
default :
if ! found . isEmpty {
items . append ( MergeWarning ( text : "Review of the latest snapshot found \( severityCounts ( found )) " , open : open ))
}
}
} else {
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 {
items . append ( MergeWarning ( text : "Changes since the last snapshot haven't been reviewed" ))
}
return ( items , r ?. state == . running )
}
private func unignored ( _ r : Review ) -> [ JSONDict ] {
r . state == . done ? findings ( r ). enumerated (). filter { r . findingStates . additionalProperties [ String ( $0 . offset )] != . ignored }. map ( \ . element ) : []
}
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 {
let questions = store . inbox . filter { $0 . taskId == model . taskId && $0 . kind == . question }. count
var warnings = model . reviewItems
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 . focusTask ( taskId , . inbox )
}
}))
}
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 {
Text ( "Merge into \( model . target ) as:" )
. 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 )
}
if ! model . toParent && ! model . coAuthors . isEmpty {
DetailsBox ( boxed : false ) {
Text ( coAuthorsLabel ( model . coAuthors ))
. foregroundStyle ( Palette . muted )
} content : {
ModalText ( text : mergeTrailers ( model . coAuthors ))
. 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 {
HStack ( spacing : 8 ) {
Text ( model . title )
. font ( Fonts . font ( 13 , weight : 700 ))
. textSelection (. enabled )
. fixedSize ( horizontal : false , vertical : true )
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
func confirmDiscard ( _ task : ByreTask , _ store : Store , _ overlays : Overlays , anchor : CGRect ?) async {
if ! ( await overlays . showConfirm ([ "Discard \" \( task . title ) \" ?" ], action : "Discard" , danger : true , anchor : anchor )) {
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 ))
}
}