import ByreAPI import SwiftUI import UserNotifications typealias LeadNote = Components.Schemas.LeadNote typealias NotificationPrefs = Components.Schemas.NotificationPrefs @MainActor private let kinds: [(key: WritableKeyPath, label: String)] = [ (\.questions, "Questions"), (\.reports, "Turn summaries"), (\.errors, "Session errors"), (\.rateLimits, "Rate limits"), ] @MainActor private var notesProject = "" 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) DefaultModelMenu() heading("What your lead has learned") Text("Your lead notes your priorities and preferences as it works with you, and starts every session with them.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) LeadNotes() } .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 DefaultModelMenu: View { @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 store.api.client.updateDefaultModel(body: .json(.init(model: m))).ok.body.json } catch { overlays.showAlert(errorText(error)) } } } } } label: { HStack(spacing: 8) { Text(store.defaultModel) 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("Model for new tasks") .padding(.top, 8) } } private struct LeadNotes: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var list: [LeadNote]? @ViewState private var failure: String? @ViewState private var loads = 0 @ViewState private var project = notesProject @ViewState private var off: (() -> Void)? var body: some View { VStack(alignment: .leading, spacing: 0) { if let list { if list.isEmpty { hint("Your lead hasn't noted anything about how you work yet.") } else { let groups = [("", "Every project")] + store.projects.map { ($0.id, $0.name) } let current = groups.contains { $0.0 == project } ? project : "" let shown = list.filter { ($0.projectId ?? "") == current } let label = { (g: (String, String)) in "\(g.1) (\(list.filter { ($0.projectId ?? "") == g.0 }.count))" } Menu { ForEach(groups, id: \.0) { g in Button(label(g)) { project = g.0 notesProject = g.0 } } } label: { HStack(spacing: 8) { Text(label(groups.first { $0.0 == current } ?? groups[0])) 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("Which notes to show") .padding(.bottom, 8) if shown.isEmpty { hint("No notes here yet.") } else { ForEach(shown, id: \.id) { note in NoteRow(note: note) } } } } else if let failure { hint(failure) } } .padding(.top, 8) .onAppear { off = store.events.add { ev in if ev.kind == "lead.note_saved" || ev.kind == "lead.note_deleted" { load() } } load() } .onDisappear { off?() off = nil loads += 1 } } private func hint(_ text: String) -> some View { Text(text).foregroundStyle(Palette.muted) } private func load() { loads += 1 let n = loads Task { do { let loaded = try await store.api.client.listLeadNotes().ok.body.json if n == loads { list = loaded } } catch { failure = errorText(error) list = nil } } } } private struct NoteRow: View { let note: LeadNote @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var frame: CGRect = .zero var body: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { Text(note.text) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) Button("delete") { Task { if !(await overlays.showConfirm(["Delete this note? Your lead stops applying it.", note.text], action: "Delete", danger: true, anchor: frame)) { return } do { _ = try await store.api.client.deleteLeadNote(path: .init(noteId: note.id)).noContent } catch { overlays.showAlert(errorText(error)) } } } .buttonStyle(SmallLinkButtonStyle()) .onGeometryChange(for: CGRect.self) { g in g.frame(in: .named("window")) } action: { f in frame = f } } .padding(.vertical, 6) .overlay(alignment: .top) { Rectangle().fill(Palette.border).frame(height: 1) } } }