Replaces heuristic auto-save (goodbye-phrase detection, idle timeout, min-message count, on-model-switch) with a standard macOS unsaved-changes gate (Save/Don't Save/Cancel) on New Chat, Clear Chat, Load Conversation, and Quit. The Save dialog gained a folder picker with inline "New Folder…" creation. Separately, the in-progress conversation is periodically mirrored to disk (DraftRecoveryService, configurable interval in Settings, default 10s) and offered back on next launch if oAI crashes or is force-quit, including the model that was selected. Two real bugs found via ObjectIdentifier/log-based diagnosis before this worked correctly: - oAIApp.init() wired AppDelegate.chatViewModel from its own @State read, which returned a throwaway ChatViewModel instance distinct from the one ContentView actually renders. Wiring moved to ContentView.onAppear. - NSApplication.shared.delegate as? AppDelegate always failed silently: @NSApplicationDelegateAdaptor registers an internal SwiftUI.AppDelegate wrapper as the real NSApp.delegate (same name, different type in a different module), which forwards protocol methods but isn't castable to our type. AppDelegate now tracks itself via a static `shared`. Also guards checkForCrashRecoveryDraft() against running under XCTestConfigurationFilePath — oAITests is app-hosted, so xcodebuild test launches this same app, and a leftover draft file on disk would otherwise hang the entire test run on a blocking NSAlert with no one to click it.
188 lines
7.1 KiB
Swift
188 lines
7.1 KiB
Swift
//
|
|
// ContentView.swift
|
|
// oAI
|
|
//
|
|
// Root navigation container — NavigationSplitView with collapsible sidebar
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of oAI.
|
|
//
|
|
// oAI 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 oAI 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
|
|
#if os(macOS)
|
|
import Darwin // uname, sysctlbyname
|
|
#endif
|
|
|
|
struct ContentView: View {
|
|
@Environment(ChatViewModel.self) var chatViewModel
|
|
private var updateService = UpdateCheckService.shared
|
|
@State private var columnVisibility: NavigationSplitViewVisibility =
|
|
SettingsService.shared.sidebarVisible ? .all : .detailOnly
|
|
@State private var showIntelWarning = false
|
|
|
|
var body: some View {
|
|
@Bindable var vm = chatViewModel
|
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
|
SidebarView()
|
|
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340)
|
|
} detail: {
|
|
ChatView(
|
|
onModelSelect: { chatViewModel.showModelSelector = true },
|
|
onProviderChange: { newProvider in
|
|
chatViewModel.changeProvider(newProvider)
|
|
}
|
|
)
|
|
}
|
|
.frame(minWidth: 860, minHeight: 560)
|
|
.onChange(of: columnVisibility) { _, newValue in
|
|
SettingsService.shared.sidebarVisible = newValue != .detailOnly
|
|
}
|
|
#if os(macOS)
|
|
.onAppear {
|
|
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
|
checkIntelWarning()
|
|
|
|
// Wire the real, environment-injected chatViewModel into the app delegate for Quit
|
|
// interception — `oAIApp.init()` used to do this by reading its own `@State`, but
|
|
// that returned a throwaway instance distinct from the one actually rendered here
|
|
// (confirmed via ObjectIdentifier logging). Deferred one run-loop tick via
|
|
// `DispatchQueue.main.async` so the modal alert reliably presents — calling it
|
|
// directly from `.onAppear` was tried before and silently failed to ever show it.
|
|
// Uses `AppDelegate.shared`, NOT `NSApplication.shared.delegate as? AppDelegate` —
|
|
// the latter always fails since `@NSApplicationDelegateAdaptor` registers an internal
|
|
// `SwiftUI.AppDelegate` wrapper as the real `NSApp.delegate`, a same-named-but-different
|
|
// type (confirmed via logging).
|
|
AppDelegate.shared?.chatViewModel = chatViewModel
|
|
DispatchQueue.main.async {
|
|
chatViewModel.checkForCrashRecoveryDraft()
|
|
}
|
|
}
|
|
.onKeyPress(.return, phases: .down) { press in
|
|
if press.modifiers.contains(.command) {
|
|
chatViewModel.sendMessage()
|
|
return .handled
|
|
}
|
|
return .ignored
|
|
}
|
|
#endif
|
|
.sheet(isPresented: $vm.showModelSelector) {
|
|
ModelSelectorView(
|
|
models: chatViewModel.availableModels,
|
|
selectedModel: chatViewModel.selectedModel,
|
|
onSelect: { model in
|
|
chatViewModel.selectModel(model)
|
|
chatViewModel.showModelSelector = false
|
|
}
|
|
)
|
|
.task {
|
|
if chatViewModel.availableModels.count <= 10 {
|
|
await chatViewModel.loadAvailableModels()
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $vm.showSettings, onDismiss: {
|
|
chatViewModel.syncFromSettings()
|
|
}) {
|
|
SettingsView(chatViewModel: chatViewModel)
|
|
}
|
|
.sheet(isPresented: $vm.showStats) {
|
|
StatsView(
|
|
stats: chatViewModel.sessionStats,
|
|
model: chatViewModel.selectedModel,
|
|
provider: chatViewModel.currentProvider
|
|
)
|
|
}
|
|
.sheet(isPresented: $vm.showHelp) {
|
|
HelpView()
|
|
}
|
|
.sheet(isPresented: $vm.showCredits) {
|
|
CreditsView(provider: chatViewModel.currentProvider)
|
|
}
|
|
.sheet(isPresented: $vm.showConversations) {
|
|
ConversationListView(
|
|
onLoad: { conversation in
|
|
chatViewModel.loadConversation(conversation)
|
|
},
|
|
onRename: { id, newName in
|
|
chatViewModel.didRenameConversation(id: id, newName: newName)
|
|
}
|
|
)
|
|
}
|
|
.sheet(item: $vm.modelInfoTarget) { model in
|
|
ModelInfoView(model: model)
|
|
}
|
|
.sheet(isPresented: $vm.showHistory) {
|
|
HistoryView(onSelect: { input in
|
|
chatViewModel.inputText = input
|
|
})
|
|
}
|
|
.alert("Intel Mac Support Ending", isPresented: $showIntelWarning) {
|
|
Button("Got It") {
|
|
UserDefaults.standard.set(true, forKey: "hasShownIntelWarning")
|
|
}
|
|
} message: {
|
|
Text("oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates.")
|
|
}
|
|
.alert("Software Update", isPresented: Binding(
|
|
get: { updateService.manualCheckMessage != nil },
|
|
set: { if !$0 { updateService.manualCheckMessage = nil } }
|
|
)) {
|
|
if updateService.updateAvailable {
|
|
if let url = updateService.downloadURL {
|
|
Button("Download v\(updateService.latestVersion ?? "")") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
Button("Release Page") { updateService.openReleasesPage() }
|
|
Button("Later", role: .cancel) { }
|
|
} else {
|
|
Button("OK", role: .cancel) { }
|
|
}
|
|
} message: {
|
|
Text(updateService.manualCheckMessage ?? "")
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func checkIntelWarning() {
|
|
guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return }
|
|
guard isIntelNative || isRosetta else { return }
|
|
showIntelWarning = true
|
|
}
|
|
|
|
private var isIntelNative: Bool {
|
|
var systemInfo = utsname()
|
|
uname(&systemInfo)
|
|
let machine = withUnsafeBytes(of: &systemInfo.machine) {
|
|
String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
|
|
}
|
|
return machine.contains("x86_64")
|
|
}
|
|
|
|
private var isRosetta: Bool {
|
|
var ret: Int32 = 0
|
|
var size = MemoryLayout<Int32>.size
|
|
sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
|
|
return ret == 1
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#Preview {
|
|
ContentView()
|
|
.environment(ChatViewModel())
|
|
}
|