import ByreAPI import SwiftUI import UserNotifications typealias NotificationPrefs = Components.Schemas.NotificationPrefs @MainActor private let kinds: [(key: WritableKeyPath, label: String)] = [ (\.questions, "Questions"), (\.reports, "Turn summaries"), (\.ready, "Ready to merge"), (\.discard, "Suggests discard"), (\.mergeProblems, "Merge problems"), (\.workflows, "Workflow finished"), (\.errors, "Session errors"), (\.rateLimits, "Rate limits"), ] struct SettingsView: View { @Environment(Store.self) private var store var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { heading("Window colour") Text("Windows for this server have a border in this colour, on every Mac you use.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) ServerColorPicker() heading("Notifications") DeviceNotifications() PrefsBox() heading("Default model") Text("New tasks you start from byre run on this model.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) 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 } if let me = store.me { heading("Review models") Text("The first model reviews your tasks' work. The second reviews work the first model wrote.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) ModelMenu(current: me.reviewModel, help: "Model that reviews your tasks") { m in try await store.api.client.updateReviewModels(body: .json(.init(reviewModel: m, secondReviewModel: me.secondReviewModel))).ok.body.json } ModelMenu(current: me.secondReviewModel, help: "Model that reviews work the first review model wrote") { m in try await store.api.client.updateReviewModels(body: .json(.init(reviewModel: me.reviewModel, secondReviewModel: m))).ok.body.json } } 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) } .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) } } 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) } } 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: Text("Notifications are blocked for byre in System Settings.").foregroundStyle(Palette.muted) case .authorized, .provisional: Text("This Mac shows notifications while byre is running.") default: VStack(alignment: .leading, spacing: 8) { Text("This Mac doesn't show notifications from byre.") Button("Turn on for this Mac") { 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() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in 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) .background(RoundedRectangle(cornerRadius: 8).stroke(Palette.border)) .padding(.top, 16) } } } private struct ModelMenu: View { let current: String let help: String let pick: (String) async throws -> User @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 { store.me = try await pick(m) } catch { overlays.showAlert(errorText(error)) } } } } } label: { HStack(spacing: 8) { Text(current) Image(systemName: "chevron.up.chevron.down") .font(.system(size: 9, weight: .semibold)) } .foregroundStyle(Palette.text) .modifier(FieldStyle()) .contentShape(Rectangle()) } .menuStyle(.button) .buttonStyle(.plain) .menuIndicator(.hidden) .fixedSize() .help(help) .padding(.top, 8) } }