Files
oai-swift/oAI/Views/Main/HeaderView.swift
T
rune 8451db1142 UI redesign Phase 1: NavigationSplitView with collapsible sidebar
- Replace root VStack with NavigationSplitView (2-column, collapsible sidebar)
- Add SidebarView: new chat button, conversation search, list with swipe actions
- Slim HeaderView to text-only (provider + model + star); remove all icon rows
- Move status pills (Online, MCP, Synced) to footer right side
- Remove version number and shortcut hints from footer
- Add resizable InputBar with drag handle (persisted height) and globe/network.slash online toggle
- Fix Norwegian menu appearing on English systems (CFBundleLocalizations in Info.plist)
- Add View menu (Model Info, History, Stats, Credits, Online Mode toggle ⌘⇧O)
- Add ⌘L as alias for Search Conversations (muscle memory for /load users)
- Add Check for Updates to Help menu with download URL from Gitea API
- Add one-time Intel/Rosetta deprecation warning on first launch
- Swift 6: fix self.Self.isoString() call sites in DatabaseService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:18:48 +02:00

244 lines
8.8 KiB
Swift

//
// 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 <https://www.gnu.org/licenses/>.
import SwiftUI
struct HeaderView: View {
let provider: Settings.Provider
let model: ModelInfo?
let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void
private let settings = SettingsService.shared
private let registry = ProviderRegistry.shared
var body: some View {
HStack(spacing: 12) {
// Provider picker dropdown only shows configured providers
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")
// Model name (clickable model selector)
Button(action: onModelSelect) {
if let model = model {
HStack(spacing: 6) {
Text(model.name)
.font(.system(size: settings.guiTextSize, weight: .medium))
.foregroundColor(.oaiPrimary)
// Capability badges
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")
// Favourite star
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")
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .bottom
)
}
}
// 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)
}