Files
oai-swift/oAI/Views/Main/HeaderView.swift
T
rune 76b58e6fdc Rename app from oAI to Confab
"oAI" reads as easily confused with OpenAI, both visually and in
casual conversation. Renamed to "Confab" throughout: Xcode
target/scheme/bundle ID (com.oai.Confab), Info.plist and Help Book
identity, all user-facing UI text, internal Log subsystem and color
identifiers, localization catalogs (6 languages, including a proper
reworded/retranslated Intel-deprecation notice), Help Book HTML
content, and docs (README/DEVELOPMENT/PRIVACY/SECURITY).

Deliberately cosmetic-only: the on-disk data folder
(~/Library/Application Support/oAI/), database/backup filenames,
Keychain service identifiers, and EncryptionService's key-derivation
inputs are all left untouched so existing conversations, settings,
and stored API keys survive the update with zero migration and no
re-entering credentials. Verified live: a real signed build
successfully decrypted a stored API key and loaded an existing
conversation database after the bundle ID change.

Also includes a small already-completed, previously uncommitted
model-release-date feature (ModelInfo/OpenRouterModels/
OpenRouterProvider/ModelInfoView) that happened to share several
files with this rename.

Gitignored on this branch and updated on disk but not part of this
commit: CLAUDE.md, RELEASE_NOTES.md, and the build*.sh scripts.
2026-08-02 14:58:14 +02:00

270 lines
9.4 KiB
Swift

//
// HeaderView.swift
// Confab
//
// Slim header — provider, model name, star only.
// Status pills and stats live in SidebarView and FooterView respectively.
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
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.confabBorder.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(.confabPrimary)
.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(.confabPrimary)
HStack(spacing: 3) {
if model.capabilities.vision {
Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.confabSecondary)
}
if model.capabilities.tools {
Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.confabSecondary)
}
if model.capabilities.online {
Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.confabSecondary)
}
if model.capabilities.imageGeneration {
Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.confabSecondary)
}
}
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.confabSecondary)
}
} else {
HStack(spacing: 4) {
Text("No model selected")
.font(.system(size: settings.guiTextSize))
.foregroundColor(.confabSecondary)
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.confabSecondary)
}
}
}
.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 : .confabSecondary)
}
.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(.confabSecondary)
}
.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(.confabSecondary)
}
.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.confabBackground)
}