2026-10-16 23:30:57 -07:00
import AppKit
2026-10-17 09:33:27 -07:00
import ByreAPI
2026-10-16 23:30:57 -07:00
import SwiftUI
enum ChatKind : Equatable {
case lead
case task ( String ?)
}
@ MainActor
@ Observable
final class ChatModel {
let kind : ChatKind
let store : Store
let builder : TranscriptBuilder
2026-10-18 08:48:57 -07:00
let member : String ?
@ ObservationIgnored private var members : [ String : String ] = [:]
2026-10-16 23:30:57 -07:00
private ( set ) var scrollRequest : ( seq : Int , id : Int , anchor : UnitPoint )?
private ( set ) var fetching = false
2026-10-17 13:14:35 -07:00
private ( set ) var older : [ ByreEvent ]?
2026-10-17 08:24:22 -07:00
private ( set ) var hasUnmerged = false
2026-10-16 23:30:57 -07:00
@ ObservationIgnored var stuck = true
2026-10-17 13:14:35 -07:00
@ ObservationIgnored var prepended = false
2026-10-17 08:24:22 -07:00
@ ObservationIgnored private var unmergedTimer : Task < Void , Never >?
2026-10-16 23:30:57 -07:00
@ ObservationIgnored private var offs : [() -> Void ] = []
@ ObservationIgnored private var focusStart = true
@ ObservationIgnored private var visibleSummaries : Set < Int64 > = []
2026-10-18 00:31:46 -07:00
@ ObservationIgnored private var activeObserver : NotificationCenter . ObservationToken ?
2026-10-18 20:58:56 -07:00
@ ObservationIgnored private var keyObserver : NotificationCenter . ObservationToken ?
2026-10-16 23:30:57 -07:00
@ ObservationIgnored private var started = false
init ( _ kind : ChatKind , _ store : Store ) {
self . kind = kind
self . store = store
let taskId : String ? = if case . task ( let id ) = kind { id } else { nil }
2026-10-18 08:48:57 -07:00
member = taskId . flatMap { store . pickedMember ( $0 ) }
2026-10-16 23:30:57 -07:00
let session : () -> AgentSession ? = { [ weak store ] in
switch kind {
case . lead : store ?. currentLead ()
case . task ( let id ): id . flatMap { store ?. currentSession ( $0 ) }
}
}
builder = TranscriptBuilder ( taskId : taskId , worktree : { [ weak store ] in
taskId . flatMap { store ?. findTask ( $0 )?. worktreePath } ?? ""
}, running : {
session ()?. state == . running
})
}
var key : String ? {
switch kind {
case . lead : "lead"
case . task ( let id ): id
}
}
var taskId : String ? {
if case . task ( let id ) = kind {
return id
}
return nil
}
private var feed : Feed ? {
switch kind {
case . lead : store . leadFeed
case . task ( let id ): id != nil ? store . selectedFeed : nil
}
}
var session : AgentSession ? {
switch kind {
case . lead : store . currentLead ()
case . task ( let id ): id . flatMap { store . currentSession ( $0 ) }
}
}
var liveSession : AgentSession ? {
session . flatMap { $0 . state != . completed ? $0 : nil }
}
var loading : Bool {
switch kind {
case . lead : store . leadLoading ()
case . task : store . transcriptLoading ()
}
}
2026-10-18 08:48:57 -07:00
var team : Bool {
member != nil
}
2026-10-16 23:30:57 -07:00
var placeholder : String {
2026-10-18 08:48:57 -07:00
if team {
return "Post to the team's board; every agent of the team gets it."
}
return kind == . lead ? "Message your lead. Paste or drop images to attach them." : "Message the agent. Paste or drop images to attach them."
}
private func shows ( _ ev : ByreEvent ) -> Bool {
guard let member , let sid = ev . agentSessionId else {
return true
}
if ev . kind == "session.created" {
members [ sid ] = str ( ev . payload . value , "member" ) ?? ""
}
let owner = members [ sid ] ?? store . sessions [ taskId ?? "" ]?. first { $0 . id == sid }?. member ?? ""
return owner . isEmpty || owner == member
2026-10-16 23:30:57 -07:00
}
var empty : String {
kind == . lead ? "" : "No task selected. Ask your lead, or pick one with ⌘K."
}
var running : Bool {
builder . open && session ?. state == . running
}
2026-10-17 08:57:26 -07:00
var subagents : Bool {
( session ?. subagents ?? 0 ) > 0
}
func working ( _ row : Row ) -> Bool {
row . pending && ( running || subagents && row . inActive ) || row . active && ( running || subagents )
}
2026-10-16 23:30:57 -07:00
var hasOlder : Bool {
kind == . lead && store . leadHasOlder ()
}
func start () {
if started {
return
}
started = true
guard let feed else {
return
}
2026-10-17 08:24:22 -07:00
Task {
await loadUnmerged ()
}
2026-10-16 23:30:57 -07:00
appended ( feed . events )
offs . append ( feed . appended . add { [ weak self ] events in
self ?. appended ( events )
})
offs . append ( feed . streamed . add { [ weak self ] ev in
2026-10-18 08:48:57 -07:00
if self ?. shows ( ev ) == true {
self ?. builder . appendDelta ( ev )
}
2026-10-16 23:30:57 -07:00
})
2026-10-18 00:31:46 -07:00
activeObserver = NotificationCenter . default . addObserver ( of : NSApplication . self , for : . didBecomeActive ) { [ weak self ] _ in
self ?. markShown ()
2026-10-16 23:30:57 -07:00
}
2026-10-18 20:58:56 -07:00
keyObserver = NotificationCenter . default . addObserver ( of : NSWindow . self , for : . didBecomeKey ) { [ weak self ] _ in
self ?. markShown ()
}
2026-10-16 23:30:57 -07:00
}
func stop () {
for off in offs {
off ()
}
offs = []
if let activeObserver {
NotificationCenter . default . removeObserver ( activeObserver )
}
activeObserver = nil
2026-10-18 20:58:56 -07:00
if let keyObserver {
NotificationCenter . default . removeObserver ( keyObserver )
}
keyObserver = nil
2026-10-17 08:24:22 -07:00
unmergedTimer ?. cancel ()
unmergedTimer = nil
2026-10-16 23:30:57 -07:00
builder . stop ()
store . drafts ?. flush ()
}
2026-10-18 08:48:57 -07:00
private func appended ( _ all : [ ByreEvent ]) {
let events = all . filter ( shows )
2026-10-17 08:24:22 -07:00
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 ()
}
}
}
2026-10-16 23:30:57 -07:00
_ = builder . append ( events )
jump ()
}
2026-10-17 08:24:22 -07:00
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 ) " )
}
}
2026-10-17 09:04:32 -07:00
func takeFocus () -> Bool {
let asked = taskId != nil && store . composerFocus == taskId
if asked {
store . composerFocus = nil
}
let take = asked || ( focusStart && session == nil )
2026-10-16 23:30:57 -07:00
focusStart = false
return take
}
func summaryVisible ( _ seq : Int64 , _ visible : Bool ) {
if visible {
visibleSummaries . insert ( seq )
} else {
visibleSummaries . remove ( seq )
}
markShown ()
}
private func markShown () {
2026-10-18 20:58:56 -07:00
guard let taskId , store . paneFocus . windowKey else {
2026-10-16 23:30:57 -07:00
return
}
for seq in visibleSummaries {
store . markSummaryRead ( taskId , seq )
}
}
func jump () {
guard let target = store . jump , target . taskId == taskId else {
return
}
let rows = builder . root . rows
guard let i = rows . firstIndex ( where : { $0 . summarySeq == target . seq }) else {
return
}
store . jump = nil
stuck = false
2026-10-17 10:54:06 -07:00
switch i > 0 ? rows [ i - 1 ]. kind : nil {
case . agent , . subagentMessage :
2026-10-16 23:30:57 -07:00
requestScroll ( rows [ i - 1 ]. id , . top )
2026-10-17 10:54:06 -07:00
default :
2026-10-16 23:30:57 -07:00
requestScroll ( rows [ i ]. id , . bottom )
}
}
private func requestScroll ( _ id : Int , _ anchor : UnitPoint ) {
scrollRequest = (( scrollRequest ?. seq ?? 0 ) + 1 , id , anchor )
}
func loadOlder () {
2026-10-17 13:14:35 -07:00
if fetching || older != nil || ! hasOlder {
2026-10-16 23:30:57 -07:00
return
}
fetching = true
Task {
do {
2026-10-17 13:14:35 -07:00
older = try await store . loadOlderLead ()
2026-10-16 23:30:57 -07:00
} catch {
log . error ( "load earlier events: \( errorText ( error ) , privacy : . public ) " )
}
fetching = false
2026-10-17 13:14:35 -07:00
}
}
func prependOlder () {
guard let events = older else {
return
}
older = nil
let n = builder . root . rows . count
builder . prepend ( events )
prepended = builder . root . rows . count > n || ! hasOlder
if ! prepended {
loadOlder ()
2026-10-16 23:30:57 -07:00
}
}
func send ( sessionId : String ?, text : String , images : [ String ]?, model : String ?, firstModel : String ) async throws {
let api = store . api . client
switch kind {
case . lead :
_ = try await api . messageLead ( body : . json (. init ( text : text , images : images , model : sessionId != nil ? model : firstModel ))). accepted
case . task ( let id ):
2026-10-18 08:48:57 -07:00
if team {
if images != nil {
throw ApiError ( status : 0 , message : "The board takes text only; drop the images to post." )
}
_ = try await api . postToBoard ( path : . init ( taskId : id ?? "" ), body : . json (. init ( text : text ))). noContent
return
}
2026-10-16 23:30:57 -07:00
if let sessionId {
_ = try await api . sendMessage ( path : . init ( sessionId : sessionId ), body : . json (. init ( text : text , images : images , model : model ))). accepted
return
}
_ = try await api . createSession ( path : . init ( taskId : id ?? "" ), body : . json (. init ( prompt : text , model : firstModel , images : images ))). created
}
}
2026-10-17 15:45:27 -07:00
func withdraw ( _ p : PendingSend , _ overlays : Overlays ) async {
guard let taskId , let inputId = p . inputId else {
return
}
let key = "message: \( taskId ) "
do {
var imgs : [ PendingImage ] = []
for id in p . imageIds {
let data = try await ImageCache . fetch (. blob ( id ), store . api )
guard let image = NSImage ( data : data ) else {
throw ApiError ( status : 0 , message : "not an image" )
}
imgs . append ( PendingImage ( data : data , image : image ))
}
_ = try await store . api . client . withdrawMessage ( path : . init ( taskId : taskId , inputId : Int64 ( inputId ))). noContent
builder . withdrawn ( inputId )
store . drafts ?. restoreText ( key , p . text )
store . images . restore ( key , imgs )
} catch {
overlays . showAlert ( errorText ( error ))
}
}
2026-10-16 23:30:57 -07:00
func interrupt ( _ sessionId : String , _ overlays : Overlays ) {
Task {
do {
_ = try await store . api . client . interruptSession ( path : . init ( sessionId : sessionId )). accepted
} catch {
overlays . showAlert ( errorText ( error ))
}
}
}
}
struct ChatView : View {
let kind : ChatKind
@ Environment ( Store . self ) private var store
@ ViewState private var model : ChatModel ?
var body : some View {
VStack ( spacing : 0 ) {
if let model {
ChatHead ( model : model )
TranscriptScroll ( model : model )
if let key = model . key {
ComposerView ( chat : model , key : key )
}
} else {
Spacer ()
}
}
. onAppear {
let m = model ?? ChatModel ( kind , store )
model = m
m . start ()
}
. onDisappear {
model ?. stop ()
}
. onChange ( of : store . jump ?. seq ) {
model ?. jump ()
}
}
}
private struct ChatHead : View {
let model : ChatModel
@ Environment ( Store . self ) private var store
var body : some View {
switch model . kind {
case . lead :
let live = model . liveSession
header {
CompactButton ( sessionId : live ?. id , state : live ?. state , usage : live . flatMap { model . builder . context [ $0 . id ] })
}
case . task ( let id ):
if let id , let task = store . findTask ( id ) {
2026-10-18 21:44:53 -07:00
if task . status == . merged {
let commit = task . mergeCommit ?? ""
header ( background : Palette . okTint ) {
Text ( "✓" ). foregroundStyle ( Palette . ok )
Text ( "Merged \( commit . isEmpty ? "" : " as \( commit . prefix ( 7 )) " ) " )
. foregroundStyle ( Palette . text )
}
} else if task . status != . active {
2026-10-16 23:30:57 -07:00
header {
Text ( task . status . rawValue )
}
} else {
let sess = store . currentSession ( task . id )
2026-10-18 08:48:57 -07:00
let busy = store . taskBusy ( task . id )
2026-10-16 23:30:57 -07:00
header {
2026-10-18 08:48:57 -07:00
if model . team {
MemberPicker ( taskId : task . id , picked : model . member )
}
2026-10-16 23:30:57 -07:00
CompactButton ( sessionId : sess ?. id , state : sess ?. state , usage : sess . flatMap { model . builder . context [ $0 . id ] })
2026-10-17 08:24:22 -07:00
SyncButton ( task : task , busy : busy )
Spacer ( minLength : 12 )
MergeButton ( task : task , busy : busy , unmerged : model . hasUnmerged )
DiscardButton ( task : task , busy : busy )
2026-10-16 23:30:57 -07:00
}
}
}
}
}
2026-10-18 21:44:53 -07:00
private func header < Content : View >( background : Color = . clear , @ ViewBuilder _ content : () -> Content ) -> some View {
2026-10-16 23:30:57 -07:00
HStack ( spacing : 8 ) {
content ()
}
2026-10-17 08:24:22 -07:00
. frame ( maxWidth : . infinity , alignment : . leading )
2026-10-16 23:30:57 -07:00
. padding (. horizontal , 16 )
. padding (. vertical , 8 )
2026-10-18 21:44:53 -07:00
. background ( background )
2026-10-16 23:30:57 -07:00
. overlay ( alignment : . bottom ) {
Rectangle (). fill ( Palette . border ). frame ( height : 1 )
}
}
}
2026-10-18 08:48:57 -07:00
private struct MemberPicker : View {
let taskId : String
let picked : String ?
@ Environment ( Store . self ) private var store
var body : some View {
HStack ( spacing : 4 ) {
ForEach ( store . team ( taskId ), id : \ . id ) { s in
PaneTabButton ( label : s . state == . running ? " \( s . member ) ●" : s . member , active : s . member == picked ) {
store . pickedMembers [ taskId ] = s . member
}
. help ( "Show \( s . member ) 's transcript ( \( s . model . isEmpty ? s . vendor . rawValue : s . model ) , \( s . state . rawValue ) )" )
}
}
}
}
2026-10-16 23:30:57 -07:00
private struct SyncButton : View {
let task : ByreTask
let busy : Bool
@ Environment ( Store . self ) private var store
@ Environment ( Overlays . self ) private var overlays
@ ViewState private var sending = false
var body : some View {
let target = store . projects . first { $0 . id == task . projectId }?. defaultBranch ?? "the default branch"
Button ( "Sync from \( target ) " ) {
sending = true
Task {
do {
_ = try await store . api . client . mergeIn ( path : . init ( taskId : task . id )). accepted
} catch {
overlays . showAlert ( errorText ( error ))
sending = false
}
}
}
. buttonStyle ( WebButtonStyle ())
. disabled ( busy || sending )
. help ( "Ask the agent to bring \( target ) into this branch and resolve any conflicts" )
. onChange ( of : busy ) {
sending = false
}
}
}
2026-10-17 08:24:22 -07:00
private struct MergeButton : View {
let task : ByreTask
let busy : Bool
let unmerged : Bool
@ Environment ( Store . self ) private var store
@ Environment ( Overlays . self ) private var overlays
var body : some View {
let target = store . projects . first { $0 . id == task . projectId }?. defaultBranch ?? "the default branch"
let held = task . mergeQueuedAt != nil
let intoParent = task . mergingIntoParent
let help = intoParent
? "Waiting for the parent task's agent to merge this subtask"
: held
? "Waiting for the merge in progress in this project to finish"
: task . mergeAfterReview
? "Merges when the running review finishes with no findings; open to merge now instead (⌘S)"
: 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)"
Button ( intoParent ? "Merging into parent" : held ? "Merge held" : task . mergeAfterReview ? "Merge after review" : "Merge" ) {
Task {
await confirmMerge ( task . id , store , overlays )
}
}
. buttonStyle ( WebButtonStyle ())
. disabled ( busy || held || intoParent || ! unmerged )
. help ( help )
}
}
private struct DiscardButton : View {
let task : ByreTask
let busy : Bool
@ Environment ( Store . self ) private var store
@ Environment ( Overlays . self ) private var overlays
@ ViewState private var frame : CGRect = . zero
var body : some View {
Button ( "Discard" ) {
Task {
await confirmDiscard ( task , store , overlays , anchor : frame )
}
}
. buttonStyle ( WebButtonStyle ( danger : true ))
. disabled ( busy )
. help ( "The agent writes the task summary, then byre deletes the worktree and branch without merging" )
. onGeometryChange ( for : CGRect . self ) { g in
g . frame ( in : . named ( "window" ))
} action : { f in
frame = f
}
}
}
2026-10-16 23:30:57 -07:00
struct CompactButton : View {
let sessionId : String ?
let state : AgentSession . StatePayload ?
let usage : Usage ?
@ Environment ( Store . self ) private var store
@ Environment ( Overlays . self ) private var overlays
@ Environment ( \ . isEnabled ) private var enabled
@ ViewState private var sending = false
var body : some View {
let pct = usage . map { $0 . window > 0 ? min ( 100 , Double ( $0 . used ) / Double ( $0 . window ) * 100 ) : 0 } ?? 0
let level = pct >= 90 ? Palette . danger : pct >= 70 ? Palette . warn : Palette . accent
let detail = usage . map { u in u . window > 0 ? " \n context: \( u . used . formatted ()) of \( u . window . formatted ()) tokens \( u . model . isEmpty ? "" : " ( \( u . model ) )" ) " : "" } ?? ""
let disabled = sessionId == nil || state != . idle || sending
2026-10-18 00:43:00 -07:00
Menu {
ForEach ( models , id : \ . self ) { m in
Button ( "Continue on \( m ) " ) {
compact ( m )
2026-10-16 23:30:57 -07:00
}
}
} label : {
Text ( "Compact" )
. foregroundStyle ( Palette . text )
. padding (. horizontal , 12 )
. padding (. vertical , 4 )
. background {
GeometryReader { g in
HStack ( spacing : 0 ) {
ZStack {
Palette . raised
level . opacity ( 0.25 )
}
. frame ( width : g . size . width * pct / 100 )
Palette . raised
}
}
. clipShape ( RoundedRectangle ( cornerRadius : 6 ))
}
. overlay ( RoundedRectangle ( cornerRadius : 6 ). stroke ( pct >= 70 ? level : Palette . border ))
. opacity ( disabled ? 0.45 : 1 )
. contentShape ( Rectangle ())
2026-10-18 00:43:00 -07:00
} primaryAction : {
compact ( nil )
2026-10-16 23:30:57 -07:00
}
2026-10-18 00:43:00 -07:00
. menuStyle (. button )
. menuIndicator (. hidden )
2026-10-16 23:30:57 -07:00
. buttonStyle (. plain )
2026-10-18 00:43:00 -07:00
. fixedSize ()
2026-10-16 23:30:57 -07:00
. disabled ( disabled )
2026-10-18 00:43:00 -07:00
. help ( "Ask the agent for a handoff, then continue in a new session with a fresh context; hold to continue on another model \( detail ) " )
2026-10-16 23:30:57 -07:00
. onChange ( of : state ) {
sending = false
}
}
2026-10-18 00:43:00 -07:00
private func compact ( _ model : String ?) {
guard let sessionId else {
return
}
sending = true
Task {
do {
_ = try await store . api . client . compactSession ( path : . init ( sessionId : sessionId ), body : . json (. init ( model : model ))). accepted
} catch {
overlays . showAlert ( errorText ( error ))
sending = false
}
}
}
2026-10-16 23:30:57 -07:00
}
private struct ScrollMetrics : Equatable {
let content : CGFloat
let visible : CGFloat
let distance : CGFloat
2026-10-17 13:14:35 -07:00
let offset : CGFloat
2026-10-16 23:30:57 -07:00
}
private struct TranscriptScroll : View {
let model : ChatModel
@ Environment ( Store . self ) private var store
@ Environment ( \ . paneAccent ) private var accent
@ ViewState private var position = ScrollPosition ( edge : . bottom )
2026-10-17 10:14:06 -07:00
@ ViewState private var scrolling = false
2026-10-16 23:30:57 -07:00
var body : some View {
let running = model . running
ScrollView {
2026-10-17 13:14:35 -07:00
VStack ( alignment : . leading , spacing : 12 ) {
2026-10-16 23:30:57 -07:00
if model . key == nil {
Muted ( text : model . empty )
} else {
if model . loading {
Muted ( text : "loading…" )
}
2026-10-17 13:14:35 -07:00
if model . hasOlder || model . older != nil {
2026-10-16 23:30:57 -07:00
Muted ( text : "loading earlier…" )
2026-10-17 13:14:35 -07:00
. opacity ( model . fetching || model . older != nil ? 1 : 0 )
2026-10-16 23:30:57 -07:00
}
ForEach ( model . builder . root . rows ) { row in
RowView ( row : row , model : model , nested : false )
}
ForEach ( model . builder . pending ) { p in
2026-10-17 15:45:27 -07:00
PendingView ( pending : p , model : model )
2026-10-16 23:30:57 -07:00
}
}
}
. scrollTargetLayout ()
2026-10-17 13:14:35 -07:00
. overlay ( alignment : . bottomLeading ) {
if running && model . key != nil {
Scanner ( color : accent )
. frame ( width : 84 , height : 2 )
. offset ( y : 8 )
}
2026-10-17 11:34:53 -07:00
}
2026-10-17 13:14:35 -07:00
. padding ( 16 )
2026-10-17 11:34:53 -07:00
}
2026-10-16 23:30:57 -07:00
. scrollPosition ( $ position )
. defaultScrollAnchor (. bottom )
. onScrollGeometryChange ( for : ScrollMetrics . self ) { g in
2026-10-17 13:14:35 -07:00
ScrollMetrics ( content : g . contentSize . height , visible : g . visibleRect . height , distance : g . contentSize . height - g . visibleRect . maxY , offset : g . contentOffset . y )
2026-10-16 23:30:57 -07:00
} action : { old , new in
2026-10-17 13:14:35 -07:00
if model . prepended && new . content != old . content {
model . prepended = false
position . scrollTo ( y : new . offset + new . content - old . content )
return
}
if new . offset < new . visible {
model . loadOlder ()
}
2026-10-17 10:14:06 -07:00
if scrolling {
model . stuck = new . distance < 80
2026-10-16 23:30:57 -07:00
return
}
2026-10-17 11:34:53 -07:00
if model . stuck && new . distance > 1 && ( new . content != old . content || new . visible != old . visible ) {
2026-10-17 10:14:06 -07:00
position . scrollTo ( edge : . bottom )
}
}
2026-10-17 11:34:53 -07:00
. onScrollPhaseChange { _ , phase in
2026-10-17 10:14:06 -07:00
scrolling = phase . isScrolling
2026-10-17 13:14:35 -07:00
if ! scrolling {
model . prependOlder ()
}
}
. onChange ( of : model . older == nil ) {
if ! scrolling {
model . prependOlder ()
}
2026-10-16 23:30:57 -07:00
}
2026-10-17 10:54:06 -07:00
. onChange ( of : model . builder . pending . last ?. id ) { old , new in
guard let new , new > old ?? 0 else {
return
}
2026-10-16 23:30:57 -07:00
model . stuck = true
position . scrollTo ( edge : . bottom )
}
. onChange ( of : model . scrollRequest ?. seq , initial : true ) {
if let r = model . scrollRequest {
position . scrollTo ( id : r . id , anchor : r . anchor )
}
}
. overlay {
Rectangle (). stroke ( running ? accent : . clear , lineWidth : 1 )
. animation (. easeInOut ( duration : 0.3 ), value : running )
}
}
}
2026-10-17 13:14:35 -07:00
private struct Scanner : NSViewRepresentable {
2026-10-17 11:34:53 -07:00
let color : Color
2026-10-17 13:14:35 -07:00
func makeNSView ( context : Context ) -> ScannerView {
ScannerView ()
2026-10-17 11:34:53 -07:00
}
2026-10-17 13:14:35 -07:00
func updateNSView ( _ v : ScannerView , context : Context ) {
let c = NSColor ( color )
CATransaction . begin ()
CATransaction . setDisableActions ( true )
v . glow . colors = [ c . withAlphaComponent ( 0 ). cgColor , c . cgColor , c . withAlphaComponent ( 0 ). cgColor ]
CATransaction . commit ()
}
}
2026-10-16 23:30:57 -07:00
2026-10-17 13:14:35 -07:00
private final class ScannerView : NSView {
let glow = CAGradientLayer ()
override init ( frame : NSRect ) {
super . init ( frame : frame )
wantsLayer = true
let dots = CALayer ()
dots . frame = CGRect ( x : 0 , y : 0 , width : 84 , height : 2 )
for x in stride ( from : 0.0 , through : 80 , by : 5 ) {
let dot = CALayer ()
dot . frame = CGRect ( x : x , y : 0 , width : 2 , height : 2 )
dot . backgroundColor = NSColor . black . cgColor
dots . addSublayer ( dot )
}
glow . bounds = CGRect ( x : 0 , y : 0 , width : 30 , height : 2 )
glow . position = CGPoint ( x : 15 , y : 1 )
glow . startPoint = CGPoint ( x : 0 , y : 0.5 )
glow . endPoint = CGPoint ( x : 1 , y : 0.5 )
let strip = CALayer ()
strip . frame = dots . frame
strip . mask = dots
strip . addSublayer ( glow )
layer ?. addSublayer ( strip )
let sweep = CABasicAnimation ( keyPath : "position.x" )
sweep . fromValue = 15
sweep . toValue = 69
sweep . duration = 1.2
sweep . autoreverses = true
sweep . repeatCount = . infinity
sweep . timingFunction = CAMediaTimingFunction ( controlPoints : 0.37 , 0 , 0.63 , 1 )
sweep . isRemovedOnCompletion = false
glow . add ( sweep , forKey : "sweep" )
}
required init ?( coder : NSCoder ) {
fatalError ( "init(coder:) is not supported" )
2026-10-16 23:30:57 -07:00
}
}
private struct PendingView : View {
let pending : PendingSend
2026-10-17 15:45:27 -07:00
let model : ChatModel
@ Environment ( Overlays . self ) private var overlays
@ ViewState private var withdrawing = false
2026-10-16 23:30:57 -07:00
var body : some View {
TrailingBubble {
VStack ( alignment : . leading , spacing : 2 ) {
2026-10-17 15:45:27 -07:00
HStack ( spacing : 8 ) {
Text ( pending . queued ? "queued…" : "sending…" )
. font ( Fonts . font ( 11 , italic : true ))
. foregroundStyle ( Palette . muted )
if pending . inputId != nil {
Button ( "× " ) {
withdrawing = true
Task {
await model . withdraw ( pending , overlays )
withdrawing = false
}
}
. buttonStyle ( ThumbRemoveStyle ())
. disabled ( withdrawing )
. help ( "Take this message back into the composer" )
}
}
2026-10-16 23:30:57 -07:00
if ! pending . images . isEmpty {
FlowLayout ( spacing : 6 ) {
ForEach ( pending . images ) { img in
Image ( nsImage : img . image )
. resizable ()
. scaledToFit ()
. frame ( maxWidth : 240 , maxHeight : 180 )
. clipShape ( RoundedRectangle ( cornerRadius : 6 ))
. overlay ( RoundedRectangle ( cornerRadius : 6 ). stroke ( Palette . border ))
}
}
. padding (. vertical , 4 )
}
2026-10-17 15:33:59 -07:00
if ! pending . imageIds . isEmpty {
MessageImages ( ids : pending . imageIds )
}
2026-10-16 23:30:57 -07:00
if ! pending . md . isEmpty {
MarkdownView ( blocks : pending . md )
}
}
. padding (. horizontal , 12 )
. padding (. vertical , 8 )
. background ( Palette . accentTint , in : RoundedRectangle ( cornerRadius : 10 ))
}
}
}
struct DetailsBox < Summary : View , Content : View >: View {
var boxed = true
var nested = false
var working = false
2026-10-17 10:54:06 -07:00
var status : ( text : String , color : Color )? = nil
2026-10-16 23:30:57 -07:00
@ ViewBuilder let summary : Summary
@ ViewBuilder let content : Content
@ Environment ( \ . paneAccent ) private var accent
@ ViewState private var open = false
2026-10-17 08:57:26 -07:00
@ ViewState private var shown = false
2026-10-16 23:30:57 -07:00
var body : some View {
VStack ( alignment : . leading , spacing : 0 ) {
Button {
open . toggle ()
} label : {
HStack ( alignment : . firstTextBaseline , spacing : 4 ) {
Text ( open ? "▾" : "▸" )
. foregroundStyle ( Palette . muted )
summary
Spacer ( minLength : 0 )
2026-10-17 10:54:06 -07:00
if let status {
Text ( status . text )
. font ( Fonts . font ( 12 ))
. foregroundStyle ( status . color )
. padding (. leading , 12 )
}
2026-10-16 23:30:57 -07:00
}
. contentShape ( Rectangle ())
}
. buttonStyle (. plain )
if open {
content
}
}
. padding (. horizontal , boxed ? 10 : 0 )
. padding (. vertical , boxed ? 4 : 0 )
. background ( boxed ? ( nested ? Palette . raised : Palette . surface ) : . clear , in : RoundedRectangle ( cornerRadius : 8 ))
. overlay {
if boxed {
2026-10-17 08:57:26 -07:00
RoundedRectangle ( cornerRadius : 8 ). stroke ( working && shown ? accent : Palette . border )
. animation (. easeInOut ( duration : 0.6 ), value : working && shown )
2026-10-16 23:30:57 -07:00
}
}
2026-10-17 08:57:26 -07:00
. onAppear {
shown = true
}
2026-10-16 23:30:57 -07:00
}
}
2026-10-18 08:48:57 -07:00
struct WhenText : View {
2026-10-16 23:30:57 -07:00
let when : Date
var body : some View {
Text ( ago ( when ))
. help ( isoText ( when ))
}
}
private struct SubagentTag : View {
let show : Bool
var body : some View {
if show {
Text ( "↳ subagent" )
. font ( Fonts . font ( 11 ))
. foregroundStyle ( Palette . accent )
. padding (. trailing , 4 )
}
}
}
struct RowView : View {
let row : Row
let model : ChatModel
let nested : Bool
@ Environment ( Store . self ) private var store
var body : some View {
if ! row . hidden {
content
}
}
private var md : MDContext {
MDContext ( taskId : row . taskId )
}
@ ViewBuilder
private var content : some View {
switch row . kind {
case . user ( let actor , let when , let images , let blocks ):
TrailingBubble {
VStack ( alignment : . leading , spacing : 2 ) {
HStack ( spacing : 4 ) {
Text ( actor )
WhenText ( when : when )
}
. font ( Fonts . font ( 11 ))
. foregroundStyle ( Palette . muted )
if ! images . isEmpty {
MessageImages ( ids : images )
}
if let blocks {
MarkdownView ( blocks : blocks , ctx : md )
}
}
. padding (. horizontal , 12 )
. padding (. vertical , 8 )
. background ( Palette . accentTint , in : RoundedRectangle ( cornerRadius : 10 ))
}
case . byrePrompt ( let brief , let when , let blocks ):
DetailsBox ( nested : nested ) {
2026-10-17 10:54:06 -07:00
Text ( styled ([( "byre" , 600 , nil ), ( " " , 400 , nil ), ( brief , 400 , Palette . muted )]))
. fixedSize ( horizontal : false , vertical : true )
2026-10-16 23:30:57 -07:00
Spacer ( minLength : 12 )
WhenText ( when : when ). font ( Fonts . font ( 12 )). foregroundStyle ( Palette . muted )
} content : {
MarkdownView ( blocks : blocks , ctx : md )
. padding (. vertical , 6 )
}
case . agent ( let blocks ):
MarkdownView ( blocks : blocks , ctx : md )
case . subagentMessage ( let when , let blocks ):
VStack ( alignment : . leading , spacing : 2 ) {
HStack ( spacing : 4 ) {
SubagentTag ( show : row . tag )
WhenText ( when : when )
}
. font ( Fonts . font ( 11 ))
. foregroundStyle ( Palette . muted )
MarkdownView ( blocks : blocks , ctx : md )
}
. padding (. leading , 10 )
. overlay ( alignment : . leading ) {
Rectangle (). fill ( Palette . accent ). frame ( width : 1 )
}
case . stream :
MarkdownView ( blocks : row . md , ctx : md )
case . thinking ( let blocks ):
2026-10-17 15:01:43 -07:00
VStack ( alignment : . leading , spacing : 2 ) {
HStack ( spacing : 4 ) {
SubagentTag ( show : row . tag )
Text ( "thinking" )
}
. font ( Fonts . font ( 11 ))
. foregroundStyle ( Palette . muted )
MarkdownView ( blocks : blocks , ctx : md )
2026-10-16 23:30:57 -07:00
}
case . tool ( let name , let brief , let input ):
2026-10-17 08:57:26 -07:00
DetailsBox ( nested : nested , working : model . working ( row )) {
2026-10-16 23:30:57 -07:00
SubagentTag ( show : row . tag )
Text ( styled ([( name , 600 , nil ), ( " " , 400 , nil ), ( brief , 400 , Palette . muted )]))
} content : {
VStack ( alignment : . leading , spacing : 6 ) {
PreBox ( text : input )
2026-10-18 08:50:34 -07:00
ToolResult ( result : row . result , unfinished : row . unfinished , started : row . started )
2026-10-16 23:30:57 -07:00
}
. padding (. vertical , 6 )
}
case . subagent ( let title , let description , let prompt ):
2026-10-17 10:54:06 -07:00
DetailsBox ( nested : nested , working : model . working ( row ), status : ( row . status , row . statusState == . done ? Palette . muted : row . statusState == . error ? Palette . danger : Palette . accent )) {
2026-10-16 23:30:57 -07:00
SubagentTag ( show : row . tag )
Text ( styled ([( title , 600 , nil ), ( " " , 400 , nil ), ( description , 400 , Palette . muted )]))
} content : {
VStack ( alignment : . leading , spacing : 6 ) {
MarkdownView ( blocks : prompt , ctx : md )
. padding (. vertical , 6 )
ForEach ( row . body !. rows ) { child in
RowView ( row : child , model : model , nested : true )
}
2026-10-18 08:50:34 -07:00
ToolResult ( result : row . result , unfinished : row . unfinished , started : row . started )
2026-10-16 23:30:57 -07:00
}
}
case . group :
2026-10-17 08:57:26 -07:00
DetailsBox ( nested : nested , working : model . working ( row )) {
2026-10-16 23:30:57 -07:00
SubagentTag ( show : row . tag )
RunSummary ( calls : row . calls )
} content : {
VStack ( alignment : . leading , spacing : 6 ) {
ForEach ( row . body !. rows ) { child in
RowView ( row : child , model : model , nested : true )
}
}
. padding (. top , 6 )
. padding (. bottom , 4 )
}
case . orphan ( let text ):
PreBox ( text : text )
case . turnEnd ( let error ):
Group {
if let error {
Text ( error )
. font ( Fonts . font ( 11 ))
. foregroundStyle ( Palette . danger )
} else {
Rectangle (). fill ( Palette . border ). frame ( height : 1 )
}
}
. onScrollVisibilityChange ( threshold : 0.01 ) { visible in
if let seq = row . summarySeq {
model . summaryVisible ( seq , visible )
}
}
case . note ( let note ):
NoteView ( note : note , cancelVisible : row . id == model . builder . lastMergeAfterReview )
case . handoff :
DetailsBox ( boxed : false ) {
NoteView ( note : row . summary ?? Note ( parts : []), cancelVisible : false )
} content : {
VStack ( alignment : . leading , spacing : 12 ) {
ForEach ( row . body !. rows ) { child in
RowView ( row : child , model : model , nested : false )
}
}
. padding (. leading , 10 )
. overlay ( alignment : . leading ) {
Rectangle (). fill ( Palette . border ). frame ( width : 1 )
}
. padding (. top , 8 )
}
case . snapshot ( let snap ):
SnapshotRow ( snap : snap , review : row . review , taskId : row . taskId )
2026-10-18 15:06:06 -07:00
case . question :
if let item = row . question {
QuestionBubble ( item : item )
}
2026-10-16 23:30:57 -07:00
}
}
}
2026-10-18 15:06:06 -07:00
private struct QuestionBubble : View {
let item : InboxItem
@ Environment ( Store . self ) private var store
@ ViewState private var hover = false
var body : some View {
let open = item . state == . open
let tone = open ? Palette . warn : Palette . muted
VStack ( alignment : . leading , spacing : 4 ) {
HStack ( spacing : 4 ) {
Text ( "asked" )
WhenText ( when : item . createdAt )
}
. font ( Fonts . font ( 11 ))
. foregroundStyle ( Palette . muted )
MarkdownView ( blocks : parseMarkdown ( jsString ( item . payload . value [ "question" ] ?? "" )), ctx : MDContext ( taskId : item . taskId ))
Text ( status )
. font ( Fonts . font ( 11 , weight : open ? 700 : 400 ))
. foregroundStyle ( tone )
. fixedSize ( horizontal : false , vertical : true )
}
. padding (. horizontal , 12 )
. padding (. vertical , 8 )
. frame ( maxWidth : . infinity , alignment : . leading )
. background ( open ? Palette . warnTint : Palette . surface , in : RoundedRectangle ( cornerRadius : 10 ))
. overlay ( RoundedRectangle ( cornerRadius : 10 ). stroke ( hover ? Palette . accent : open ? Palette . warn . opacity ( 0.55 ) : Palette . border ))
. contentShape ( Rectangle ())
. onTapGesture {
store . showQuestion ( item . taskId , item . id )
}
2026-10-18 20:55:27 -07:00
. environment ( \ . choiceTap , { [ store , item ] in
store . showQuestion ( item . taskId , item . id )
})
2026-10-18 15:06:06 -07:00
. onHover { hover = $0 }
. help ( "Show this question in the Questions tab" )
}
private var status : String {
let answer = item . responses . last
switch item . state {
case . open :
return "waiting for your answer"
case . dismissed :
return item . resolvedBy == nil ? "withdrawn by the agent" : "dismissed by \( item . resolvedBy ! ) "
default :
guard let answer else {
return item . state . rawValue
}
if let options = answer . options , ! options . isEmpty {
return " \( answer . user ) chose: \( options . joined ( separator : "; " )) "
}
return " \( answer . user ) answered: \( firstLine ( answer . text ?? "" )) "
}
}
private func firstLine ( _ s : String ) -> String {
let line = trimmed ( s ). components ( separatedBy : " \n " ). first ?? ""
return line . count > 120 ? " \( line . prefix ( 120 )) …" : line
}
}
2026-10-16 23:30:57 -07:00
private struct ToolResult : View {
let result : String ?
let unfinished : Bool
2026-10-18 08:50:34 -07:00
let started : Date ?
2026-10-16 23:30:57 -07:00
var body : some View {
if let result {
PreBox ( text : result )
2026-10-18 08:50:34 -07:00
} else if unfinished || started == nil {
label ( unfinished ? "no result" : "running…" )
2026-10-16 23:30:57 -07:00
} else {
2026-10-18 08:50:34 -07:00
TimelineView (. periodic ( from : . now , by : 1 )) { ctx in
label ( "running… \( elapsed ( ctx . date . timeIntervalSince ( started ! ))) " )
}
}
}
private func label ( _ text : String ) -> some View {
Text ( text )
. font ( Fonts . font ( 12 , italic : true ))
. foregroundStyle ( Palette . muted )
. padding (. vertical , 4 )
}
private func elapsed ( _ t : TimeInterval ) -> String {
let s = max ( 0 , Int ( t ))
if s < 60 {
return " \( s ) s"
2026-10-16 23:30:57 -07:00
}
2026-10-18 08:50:34 -07:00
return " \( s / 60 ) m \( s % 60 < 10 ? "0" : "" )\( s % 60 ) s"
2026-10-16 23:30:57 -07:00
}
}
private struct RunSummary : View {
let calls : [ Call ]
var body : some View {
var order : [ String ] = []
var counts : [ String : ( call : Call , n : Int )] = [:]
for c in calls {
let key = " \( c . name ) \0 \( c . target ) "
if counts [ key ] == nil {
order . append ( key )
counts [ key ] = ( c , 0 )
}
counts [ key ] ! . n += 1
}
var parts : [( String , CGFloat , Color ?)] = []
for ( i , key ) in order . enumerated () {
let ( call , n ) = counts [ key ] !
if i > 0 {
parts . append (( ", " , 400 , nil ))
}
parts . append (( call . name , 600 , nil ))
if ! call . target . isEmpty {
parts . append (( " \( call . target ) " , 400 , Palette . muted ))
}
if n > 1 {
parts . append (( " × \( n ) " , 400 , Palette . muted ))
}
}
return Text ( styled ( parts ))
}
}
private func styled ( _ parts : [( String , CGFloat , Color ?)], size : CGFloat = 12 ) -> AttributedString {
var out = AttributedString ()
for ( text , weight , color ) in parts {
var a = AttributedString ( text )
a . font = Fonts . font ( size , weight : weight )
if let color {
a . foregroundColor = color
}
out += a
}
return out
}
struct NoteView : View {
let note : Note
let cancelVisible : Bool
@ Environment ( Store . self ) private var store
@ Environment ( Overlays . self ) private var overlays
@ ViewState private var cancelling = false
var body : some View {
2026-10-18 15:06:06 -07:00
let color = note . style == . error ? Palette . danger : Palette . muted
2026-10-16 23:30:57 -07:00
HStack ( alignment : . firstTextBaseline , spacing : 12 ) {
Text ( text ())
. font ( Fonts . font ( 11 ))
. foregroundStyle ( color )
. textSelection (. enabled )
. fixedSize ( horizontal : false , vertical : true )
Spacer ( minLength : 0 )
if let when = note . when {
WhenText ( when : when )
. font ( Fonts . font ( 11 ))
. foregroundStyle ( color )
}
if let taskId = note . cancelMergeAfterReview , cancelVisible , store . findTask ( taskId )?. mergeAfterReview == true {
Button ( "Cancel" ) {
cancelling = true
Task {
do {
_ = try await store . api . client . cancelMergeAfterReview ( path : . init ( taskId : taskId )). ok
} catch {
overlays . showAlert ( errorText ( error ))
}
cancelling = false
}
}
. buttonStyle ( SmallLinkButtonStyle ())
. disabled ( cancelling )
. help ( "Withdraw this request; nothing is merged" )
}
}
}
private func text () -> AttributedString {
var out = AttributedString ()
for part in note . parts {
switch part {
case . text ( let s ):
out += AttributedString ( s )
case . task ( let id ):
guard let task = store . findTask ( id ) else {
out += AttributedString ( id )
continue
}
var a = AttributedString ( task . title )
if task . status == . active {
a . link = AppLink . task ( task . id )
a . foregroundColor = taskPalette [ task . color ]
}
out += a
case . url ( let port ):
let url = previewURL ( store . api . origin , Int ( port ) ?? 0 )
var a = AttributedString ( url . absoluteString )
a . link = url
a . foregroundColor = Palette . accent
out += a
}
}
return out
}
}
func previewURL ( _ origin : URL , _ port : Int ) -> URL {
var c = URLComponents ()
c . scheme = "https"
c . host = origin . host ()
c . port = port
c . path = "/"
return c . url !
}
private struct SnapshotRow : View {
let snap : Snapshot
let review : Review ?
let taskId : String ?
@ Environment ( Store . self ) private var store
@ Environment ( Overlays . self ) private var overlays
@ ViewState private var forking = false
var body : some View {
2026-10-17 10:54:06 -07:00
let time = clockText ( snap . createdAt )
2026-10-16 23:30:57 -07:00
HStack ( spacing : 8 ) {
Rectangle (). fill ( Palette . border ). frame ( height : 1 )
Text ( "snapshot # \( snap . n ) " )
Button ( "changes" ) {
store . pinSnapshot ( snap . n )
store . focusTab (. changes )
}
. buttonStyle ( SmallLinkButtonStyle ())
. help ( "Show snapshot \( snap . n ) in the Changes tab" )
Button ( "fork" ) {
guard let taskId else {
return
}
forking = true
Task {
do {
let task = try await store . api . client . forkSnapshot ( path : . init ( taskId : taskId , n : snap . n )). created . body . json
store . focusTask ( task . id )
} catch {
forking = false
overlays . showAlert ( errorText ( error ))
}
}
}
. buttonStyle ( SmallLinkButtonStyle ())
. disabled ( forking )
. help ( "Start a new task from this snapshot, its agent continuing the conversation as it stood here" )
2026-10-18 15:06:06 -07:00
if snap . trigger != . answer {
reviewButton
}
2026-10-16 23:30:57 -07:00
Rectangle (). fill ( Palette . border ). frame ( height : 1 )
}
. font ( Fonts . font ( 11 ))
. foregroundStyle ( Palette . muted )
. help ( " \( snap . trigger . rawValue . replacingOccurrences ( of : "_" , with : " " )) · \( time ) " )
}
private var reviewButton : some View {
let n = review . map ( findingCount ) ?? 0
return Button {
store . showReview ( snap . n )
} label : {
2026-10-17 10:54:06 -07:00
Text ( "review" )
. font ( Fonts . font ( 11 , weight : n > 0 ? 700 : 400 ))
. overlay ( alignment : . topTrailing ) {
if let review {
switch review . state {
2026-10-17 23:21:40 -07:00
case . running , . failed :
2026-10-17 10:54:06 -07:00
EmptyView ()
case . skipped :
Text ( "●" ). font ( Fonts . font ( 11 , weight : 700 )). foregroundStyle ( Palette . muted ). help ( "skipped: no changes" )
. fixedSize ()
. offset ( x : 13 , y : - 7 )
case . done :
if n == 0 {
Text ( "✓" ). font ( Fonts . font ( 11 , weight : 700 )). foregroundStyle ( Palette . ok ). help ( review . findingStates . additionalProperties . isEmpty ? "no findings" : "every finding addressed" )
. fixedSize ()
. offset ( x : 13 , y : - 7 )
} else {
Text ( " \( n ) " )
. font ( Fonts . font ( 10 , weight : 700 ))
. foregroundStyle ( Palette . danger )
. frame ( height : 14 )
. padding (. horizontal , 5 )
. background ( Palette . dangerTint , in : Capsule ())
. help ( " \( n ) open findings" )
. fixedSize ()
. offset ( x : 15 , y : - 9 )
}
2026-10-16 23:30:57 -07:00
}
}
}
}
2026-10-18 10:45:13 -07:00
. buttonStyle ( SmallLinkButtonStyle ( color : n > 0 ? Palette . warn : Palette . accent , fill : review ?. state == . running ? Palette . accentTint : . clear ))
2026-10-17 23:21:40 -07:00
. help ( review ?. state == . running ? "Review in progress; show snapshot \( snap . n ) 's review in the Reviews tab" : "Show the review of snapshot \( snap . n ) in the Reviews tab" )
2026-10-16 23:30:57 -07:00
}
}