2026-10-17 09:33:27 -07:00
import ByreAPI
2026-10-17 07:26:18 -07:00
import SwiftUI
import UserNotifications
typealias NotificationPrefs = Components . Schemas . NotificationPrefs
@ MainActor
private let kinds : [( key : WritableKeyPath < NotificationPrefs , Bool >, label : String )] = [
( \ . questions , "Questions" ),
2026-10-19 19:35:23 -07:00
( \ . ready , "Ready to merge" ),
( \ . discard , "Suggests discard" ),
( \ . mergeProblems , "Merge problems" ),
( \ . workflows , "Workflow finished" ),
2026-10-17 07:26:18 -07:00
( \ . errors , "Session errors" ),
( \ . rateLimits , "Rate limits" ),
2026-10-19 19:56:24 -07:00
( \ . reports , "Turn summaries" ),
2026-10-17 07:26:18 -07:00
]
struct SettingsView : View {
@ Environment ( Store . self ) private var store
var body : some View {
ScrollView {
VStack ( alignment : . leading , spacing : 0 ) {
2026-10-20 22:28:36 -07:00
heading ( "Window color" )
2026-10-21 22:00:54 -07:00
Text ( "Windows for this server have a border in this color, on every device you use." )
2026-10-18 20:58:56 -07:00
. foregroundStyle ( Palette . muted )
. fixedSize ( horizontal : false , vertical : true )
ServerColorPicker ()
2026-10-20 19:32:27 -07:00
heading ( "Font size" )
2026-10-21 22:00:54 -07:00
Text ( "The size of body text and of code, on every device you use. Other text grows and shrinks with them." )
2026-10-20 19:32:27 -07:00
. foregroundStyle ( Palette . muted )
. fixedSize ( horizontal : false , vertical : true )
2026-10-21 10:15:26 -07:00
FontSizeStepper ( label : "Text" , size : FontSize . shared . body ) { n in
FontSize . shared . set ( body : n , code : FontSize . shared . code )
}
FontSizeStepper ( label : "Code" , size : FontSize . shared . code ) { n in
FontSize . shared . set ( body : FontSize . shared . body , code : n )
}
2026-10-17 07:26:18 -07:00
heading ( "Notifications" )
DeviceNotifications ()
PrefsBox ()
2026-10-18 00:57:51 -07:00
heading ( "Default model" )
Text ( "New tasks you start from byre run on this model." )
. foregroundStyle ( Palette . muted )
. fixedSize ( horizontal : false , vertical : true )
2026-10-18 22:56:33 -07:00
ModelMenu ( current : store . defaultModel , help : "Model for new tasks" ) { m in
try await store . api . client . updateDefaultModel ( body : . json (. init ( model : m ))). ok . body . json
}
2026-10-21 08:59:03 -07:00
heading ( "Saved commands" )
Text ( "Each is a button in the Terminal tab of every task on this server. It opens a new shell in the task's worktree and runs the command there." )
. foregroundStyle ( Palette . muted )
. fixedSize ( horizontal : false , vertical : true )
SavedCommands ()
2026-10-20 08:55:37 -07:00
heading ( "iCloud sync" )
SyncState ()
2026-10-21 22:00:54 -07:00
#if os ( macOS )
2026-10-18 22:17:54 -07:00
heading ( "This Mac's permissions" )
Text ( "The macOS privacy grants byre's app needs for agents to see and control this Mac. Checking asks macOS for any that are missing." )
. foregroundStyle ( Palette . muted )
. fixedSize ( horizontal : false , vertical : true )
PermissionsCheck ( run : checkLocalPermissions )
2026-10-21 22:00:54 -07:00
#endif
2026-10-17 07:26:18 -07:00
}
. padding (. horizontal , 16 )
. padding (. top , 8 )
. padding (. bottom , 16 )
. frame ( maxWidth : . infinity , alignment : . leading )
}
. frame ( maxWidth : . infinity , maxHeight : . infinity )
. background ( Palette . bg )
}
private func heading ( _ text : String ) -> some View {
Text ( text )
. font ( Fonts . font ( 13 , weight : 700 ))
. padding (. top , 16 )
. padding (. bottom , 6 )
}
}
2026-10-18 20:58:56 -07:00
private struct ServerColorPicker : View {
@ Environment ( Store . self ) private var store
var body : some View {
let origin = store . api . origin . absoluteString
let current = Servers . shared . record ( origin )?. color
HStack ( spacing : 4 ) {
ForEach ( taskPalette . indices , id : \ . self ) { i in
Button {
Servers . shared . setColor ( origin , i )
} label : {
Circle ()
. fill ( taskPalette [ i ])
. frame ( width : 18 , height : 18 )
. padding ( 3 )
. overlay ( Circle (). stroke ( i == current ? Palette . text : . clear , lineWidth : 1.5 ))
. contentShape ( Circle ())
}
. buttonStyle (. plain )
}
}
. padding (. top , 8 )
}
}
2026-10-20 19:32:27 -07:00
private struct FontSizeStepper : View {
2026-10-21 10:15:26 -07:00
let label : String
let size : Int
let set : ( Int ) -> Void
2026-10-20 19:32:27 -07:00
var body : some View {
HStack ( spacing : 8 ) {
2026-10-21 10:15:26 -07:00
Text ( label )
. frame ( width : 48 , alignment : . leading )
2026-10-20 19:32:27 -07:00
Button ( "− " ) {
2026-10-21 10:15:26 -07:00
set ( size - 1 )
2026-10-20 19:32:27 -07:00
}
. buttonStyle ( WebButtonStyle ())
2026-10-21 10:15:26 -07:00
. disabled ( size <= FontSize . range . lowerBound )
Text ( " \( size ) pt" )
2026-10-20 19:32:27 -07:00
. monospacedDigit ()
Button ( "+" ) {
2026-10-21 10:15:26 -07:00
set ( size + 1 )
2026-10-20 19:32:27 -07:00
}
. buttonStyle ( WebButtonStyle ())
2026-10-21 10:15:26 -07:00
. disabled ( size >= FontSize . range . upperBound )
2026-10-20 19:32:27 -07:00
}
. padding (. top , 8 )
}
}
2026-10-20 08:55:37 -07:00
private struct SyncState : View {
var body : some View {
TimelineView (. periodic ( from : . now , by : 30 )) { _ in
if let sync = Servers . shared . sync {
if sync . ok {
2026-10-21 22:00:54 -07:00
Text ( "This \( deviceKind ) shares its servers, window colors, font sizes, drafts and task visits through iCloud. Last synced \( ago ( sync . at )) ." )
2026-10-20 08:55:37 -07:00
. fixedSize ( horizontal : false , vertical : true )
} else {
2026-10-21 22:00:54 -07:00
Text ( "iCloud sync is failing on this \( deviceKind ) , so changes made here aren't reaching your other devices. Failed \( ago ( sync . at )) : \( sync . error ?? "" ) " )
2026-10-20 08:55:37 -07:00
. foregroundStyle ( Palette . danger )
. textSelection (. enabled )
. fixedSize ( horizontal : false , vertical : true )
}
} else {
Text ( "No iCloud sync has finished since byre started." ). foregroundStyle ( Palette . muted )
}
}
}
}
2026-10-17 07:26:18 -07:00
private struct DeviceNotifications : View {
@ ViewState private var status : UNAuthorizationStatus ?
@ ViewState private var asking = false
var body : some View {
Group {
switch status {
case . none :
EmptyView ()
case . denied :
2026-10-21 22:00:54 -07:00
Text ( touchOnly ? "Notifications are blocked for byre in Settings." : "Notifications are blocked for byre in System Settings." ). foregroundStyle ( Palette . muted )
2026-10-17 07:26:18 -07:00
case . authorized , . provisional :
2026-10-21 22:00:54 -07:00
Text ( touchOnly ? "This iPhone shows notifications while byre is open." : "This Mac shows notifications while byre is running." )
2026-10-17 07:26:18 -07:00
default :
VStack ( alignment : . leading , spacing : 8 ) {
2026-10-21 22:00:54 -07:00
Text ( "This \( deviceKind ) doesn't show notifications from byre." )
Button ( "Turn on for this \( deviceKind ) " ) {
2026-10-17 07:26:18 -07:00
asking = true
Task {
_ = try ? await UNUserNotificationCenter . current (). requestAuthorization ( options : [. alert , . sound ])
await read ()
asking = false
}
}
. buttonStyle ( WebButtonStyle ())
. disabled ( asking )
}
}
}
. padding (. bottom , 8 )
. task {
await read ()
}
2026-10-21 22:00:54 -07:00
. onReceive ( NotificationCenter . default . publisher ( for : appBecameActive )) { _ in
2026-10-17 07:26:18 -07:00
Task {
await read ()
}
}
}
private func read () async {
status = await UNUserNotificationCenter . current (). notificationSettings (). authorizationStatus
}
}
private struct PrefsBox : View {
@ Environment ( Store . self ) private var store
@ Environment ( Overlays . self ) private var overlays
var body : some View {
if let me = store . me {
VStack ( alignment : . leading , spacing : 4 ) {
Text ( "Notify me about" )
. foregroundStyle ( Palette . muted )
. padding (. horizontal , 4 )
. background ( Palette . bg )
. offset ( y : - 18 )
. padding (. bottom , - 18 )
ForEach ( kinds , id : \ . label ) { kind in
Toggle ( kind . label , isOn : Binding ( get : { me . notificationPrefs [ keyPath : kind . key ] }, set : { on in
var prefs = me . notificationPrefs
prefs [ keyPath : kind . key ] = on
Task {
do {
store . me = try await store . api . client . updateNotificationPrefs ( body : . json ( prefs )). ok . body . json
} catch {
overlays . showAlert ( errorText ( error ))
}
}
}))
. toggleStyle (. checkbox )
. tint ( Palette . accent )
}
}
. padding (. horizontal , 12 )
. padding (. top , 8 )
. padding (. bottom , 10 )
. frame ( maxWidth : . infinity , alignment : . leading )
2026-10-18 16:24:58 -07:00
. background ( RoundedRectangle ( cornerRadius : 8 ). stroke ( Palette . border ))
2026-10-17 07:26:18 -07:00
. padding (. top , 16 )
}
}
}
2026-10-21 08:59:03 -07:00
private struct SavedCommands : View {
@ Environment ( Store . self ) private var store
@ Environment ( Overlays . self ) private var overlays
@ ViewState private var list : [ SavedCommand ] = []
@ ViewState private var editing : String ?
@ ViewState private var name = ""
@ ViewState private var command = ""
@ ViewState private var off : (() -> Void )?
var body : some View {
VStack ( alignment : . leading , spacing : 0 ) {
ForEach ( list , id : \ . id ) { c in
if editing == c . id {
form ( c . id )
} else {
HStack ( alignment : . firstTextBaseline , spacing : 12 ) {
VStack ( alignment : . leading , spacing : 2 ) {
Text ( c . name )
Text ( c . command )
2026-10-21 11:45:03 -07:00
. font ( Fonts . font ( 12 , mono : true ))
2026-10-21 08:59:03 -07:00
. foregroundStyle ( Palette . muted )
. textSelection (. enabled )
. fixedSize ( horizontal : false , vertical : true )
}
. frame ( maxWidth : . infinity , alignment : . leading )
Button ( "Edit" ) {
editing = c . id
name = c . name
command = c . command
}
. buttonStyle ( SmallLinkButtonStyle ())
Button ( "Delete" ) {
delete ( c )
}
. buttonStyle ( SmallLinkButtonStyle ( color : Palette . danger ))
}
. padding (. vertical , 6 )
. overlay ( alignment : . top ) {
Rectangle (). fill ( Palette . border ). frame ( height : 1 )
}
}
}
if editing == nil {
form ( nil )
}
}
. padding (. top , 8 )
. onAppear {
off = store . events . add { ev in
if ev . kind == "command.saved" || ev . kind == "command.deleted" {
load ()
}
}
load ()
}
. onDisappear {
off ?()
off = nil
}
}
private func form ( _ id : String ?) -> some View {
let valid = ! name . trimmingCharacters ( in : . whitespaces ). isEmpty && ! command . trimmingCharacters ( in : . whitespaces ). isEmpty
return HStack ( spacing : 6 ) {
TextField ( "Name" , text : $ name )
. modifier ( FieldStyle ())
2026-10-21 22:00:54 -07:00
. frame ( width : touchOnly ? 100 : 180 )
2026-10-21 08:59:03 -07:00
. onSubmit {
save ( id , valid )
}
TextField ( "Command" , text : $ command )
2026-10-21 11:45:03 -07:00
. font ( Fonts . font ( 13 , mono : true ))
2026-10-21 08:59:03 -07:00
. modifier ( FieldStyle ())
. onSubmit {
save ( id , valid )
}
Button ( id == nil ? "Add" : "Save" ) {
save ( id , valid )
}
. buttonStyle ( WebButtonStyle ())
. disabled ( ! valid )
if id != nil {
Button ( "Cancel" ) {
editing = nil
name = ""
command = ""
}
. buttonStyle ( WebButtonStyle ())
}
}
. padding (. vertical , 6 )
}
private func load () {
Task {
do {
list = try await store . api . client . listSavedCommands (). ok . body . json
} catch {
overlays . showAlert ( errorText ( error ))
}
}
}
private func save ( _ id : String ?, _ valid : Bool ) {
if ! valid {
return
}
let body = Components . Schemas . SavedCommandSave ( name : name , command : command )
Task {
do {
if let id {
_ = try await store . api . client . updateSavedCommand ( path : . init ( commandId : id ), body : . json ( body )). ok . body . json
} else {
_ = try await store . api . client . createSavedCommand ( body : . json ( body )). created . body . json
}
editing = nil
name = ""
command = ""
} catch {
overlays . showAlert ( errorText ( error ))
}
}
}
private func delete ( _ c : SavedCommand ) {
Task {
do {
_ = try await store . api . client . deleteSavedCommand ( path : . init ( commandId : c . id )). noContent
} catch {
overlays . showAlert ( errorText ( error ))
}
}
}
}
2026-10-18 22:56:33 -07:00
private struct ModelMenu : View {
let current : String
let help : String
let pick : ( String ) async throws -> User
2026-10-18 00:57:51 -07:00
@ Environment ( Store . self ) private var store
@ Environment ( Overlays . self ) private var overlays
var body : some View {
Menu {
ForEach ( models , id : \ . self ) { m in
Button ( m ) {
Task {
do {
2026-10-18 22:56:33 -07:00
store . me = try await pick ( m )
2026-10-18 00:57:51 -07:00
} catch {
overlays . showAlert ( errorText ( error ))
}
}
}
}
} label : {
HStack ( spacing : 8 ) {
2026-10-18 22:56:33 -07:00
Text ( current )
2026-10-18 00:57:51 -07:00
Image ( systemName : "chevron.up.chevron.down" )
2026-10-21 10:18:06 -07:00
. font ( Fonts . font ( 9 , weight : 600 ))
2026-10-18 00:57:51 -07:00
}
. foregroundStyle ( Palette . text )
. modifier ( FieldStyle ())
. contentShape ( Rectangle ())
}
. menuStyle (. button )
. buttonStyle (. plain )
. menuIndicator (. hidden )
. fixedSize ()
2026-10-18 22:56:33 -07:00
. help ( help )
2026-10-18 00:57:51 -07:00
. padding (. top , 8 )
}
}