// // HeaderView.swift // oAI // // Slim header — provider, model name, star only. // Status pills and stats live in SidebarView and FooterView respectively. // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . import SwiftUI struct HeaderView: View { let provider: Settings.Provider let model: ModelInfo? let onModelSelect: () -> Void let onProviderChange: (Settings.Provider) -> Void var conversationName: String? = nil var hasUnsavedChanges: Bool = false var onQuickSave: (() -> Void)? = nil private let settings = SettingsService.shared private let registry = ProviderRegistry.shared var body: some View { ZStack { // Left: provider + model + star HStack(spacing: 12) { providerMenu modelButton starButton Spacer() } // Center: conversation title (macOS document-title style) conversationTitle } .padding(.horizontal, 16) .padding(.vertical, 8) .background(.ultraThinMaterial) .overlay( Rectangle() .fill(Color.oaiBorder.opacity(0.5)) .frame(height: 1), alignment: .bottom ) } // MARK: - Conversation title (center) @ViewBuilder private var conversationTitle: some View { if let name = conversationName { Button(action: { if hasUnsavedChanges { onQuickSave?() } }) { HStack(spacing: 5) { if hasUnsavedChanges { Circle() .fill(Color.orange) .frame(width: 6, height: 6) } Text(name) .font(.system(size: settings.guiTextSize - 1, weight: .medium)) .foregroundColor(.oaiPrimary) .lineLimit(1) .frame(maxWidth: 300) } } .buttonStyle(.plain) .disabled(!hasUnsavedChanges) .help(hasUnsavedChanges ? "Unsaved changes — click to save" : "Saved") .animation(.easeInOut(duration: 0.2), value: hasUnsavedChanges) .animation(.easeInOut(duration: 0.2), value: name) } } // MARK: - Subviews (extracted so ZStack stays readable) private var providerMenu: some View { Menu { ForEach(registry.configuredProviders, id: \.self) { p in Button { onProviderChange(p) } label: { HStack { Image(systemName: p.iconName) Text(p.displayName) if p == provider { Image(systemName: "checkmark") } } } } } label: { HStack(spacing: 4) { Image(systemName: provider.iconName) .font(.system(size: settings.guiTextSize - 2)) Text(provider.displayName) .font(.system(size: settings.guiTextSize - 2, weight: .medium)) Image(systemName: "chevron.up.chevron.down") .font(.system(size: 8)) .opacity(0.7) } .foregroundColor(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.providerColor(provider)) .cornerRadius(4) } .menuStyle(.borderlessButton) .fixedSize() .help("Switch provider") } private var modelButton: some View { Button(action: onModelSelect) { if let model = model { HStack(spacing: 6) { Text(model.name) .font(.system(size: settings.guiTextSize, weight: .medium)) .foregroundColor(.oaiPrimary) HStack(spacing: 3) { if model.capabilities.vision { Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary) } if model.capabilities.tools { Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary) } if model.capabilities.online { Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary) } if model.capabilities.imageGeneration { Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary) } } Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary) } } else { HStack(spacing: 4) { Text("No model selected") .font(.system(size: settings.guiTextSize)) .foregroundColor(.oaiSecondary) Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary) } } } .buttonStyle(.plain) .help("Select model") } @ViewBuilder private var starButton: some View { if let model = model { let isFav = settings.favoriteModelIds.contains(model.id) Button(action: { settings.toggleFavoriteModel(model.id) }) { Image(systemName: isFav ? "star.fill" : "star") .font(.system(size: settings.guiTextSize - 3)) .foregroundColor(isFav ? .yellow : .oaiSecondary) } .buttonStyle(.plain) .help(isFav ? "Remove from favorites" : "Add to favorites") } } } // MARK: - Status Pills (used by SidebarView) struct StatusPill: View { let icon: String let label: LocalizedStringKey let color: Color var body: some View { HStack(spacing: 3) { Circle() .fill(color) .frame(width: 6, height: 6) Text(label) .font(.system(size: 10, weight: .medium)) .foregroundColor(.oaiSecondary) } .padding(.horizontal, 6) .padding(.vertical, 2) .background(color.opacity(0.1), in: Capsule()) } } struct SyncStatusPill: View { private let gitSync = GitSyncService.shared private enum SyncPillState { case notConfigured, syncing, error(String), synced(Date?) } @State private var syncState: SyncPillState = .notConfigured @State private var syncColor: Color = .secondary private var syncLabel: LocalizedStringKey { switch syncState { case .notConfigured: return "Sync" case .syncing: return "Syncing" case .error: return "Error" case .synced: return "Synced" } } private var tooltipText: LocalizedStringKey { switch syncState { case .notConfigured: return "Sync not configured" case .syncing: return "Syncing..." case .error(let msg): return "Sync failed: \(msg)" case .synced(let date): guard let date else { return "Synced" } let rel = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now) return "Last synced: \(rel)" } } var body: some View { HStack(spacing: 3) { Circle() .fill(syncColor) .frame(width: 6, height: 6) Text(syncLabel) .font(.system(size: 10, weight: .medium)) .foregroundColor(.oaiSecondary) } .padding(.horizontal, 6) .padding(.vertical, 2) .background(syncColor.opacity(0.1), in: Capsule()) .help(tooltipText) .onAppear { updateState() } .onChange(of: gitSync.syncStatus) { updateState() } .onChange(of: gitSync.isSyncing) { updateState() } .onChange(of: gitSync.lastSyncError) { updateState() } } private func updateState() { if let error = gitSync.lastSyncError { syncState = .error(error); syncColor = .red } else if gitSync.isSyncing { syncState = .syncing; syncColor = .orange } else if gitSync.syncStatus.isCloned { syncState = .synced(gitSync.syncStatus.lastSyncTime); syncColor = .green } else { syncState = .notConfigured; syncColor = .secondary } } } #Preview { VStack { HeaderView( provider: .openrouter, model: ModelInfo.mockModels.first, onModelSelect: {}, onProviderChange: { _ in } ) Spacer() } .background(Color.oaiBackground) }