Add unsaved-changes save prompt and crash-recovery draft
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.
This commit is contained in:
+38
-3
@@ -24,12 +24,50 @@
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
/// Intercepts Quit so an unsaved conversation gets the standard "Do you want to save?" prompt
|
||||
/// (via `ChatViewModel.confirmDiscardIfNeeded`) with a real chance to cancel the quit.
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// `NSApplication.shared.delegate as? AppDelegate` (formerly used in ContentView.onAppear to
|
||||
// wire `chatViewModel`) ALWAYS fails: `@NSApplicationDelegateAdaptor` registers an internal
|
||||
// `SwiftUI.AppDelegate` wrapper as the real `NSApp.delegate`, which forwards protocol methods
|
||||
// (like `applicationShouldTerminate`) to this instance — but querying `NSApp.delegate` returns
|
||||
// that SwiftUI wrapper, a same-named-but-different type, not this class. Confirmed via logging
|
||||
// ("delegate is Optional(<SwiftUI.AppDelegate: ...>)"). Track our own instance directly instead.
|
||||
static var shared: AppDelegate?
|
||||
|
||||
var chatViewModel: ChatViewModel?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
AppDelegate.shared = self
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard let chatViewModel, chatViewModel.hasUnsavedChanges else { return .terminateNow }
|
||||
var shouldTerminate = false
|
||||
chatViewModel.confirmDiscardIfNeeded(
|
||||
then: { shouldTerminate = true },
|
||||
onCancel: { shouldTerminate = false }
|
||||
)
|
||||
return shouldTerminate ? .terminateNow : .terminateCancel
|
||||
}
|
||||
|
||||
// `chatViewModel` is wired from `ContentView.onAppear`, not from `oAIApp.init()` — reading
|
||||
// the `@State private var chatViewModel` there returns a throwaway instance distinct from
|
||||
// the one SwiftUI actually renders (confirmed via ObjectIdentifier logging: two different
|
||||
// addresses), which silently broke crash-recovery restore. `ContentView.onAppear` reads the
|
||||
// same environment-injected instance the view tree observes, so it's guaranteed correct.
|
||||
}
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct oAIApp: App {
|
||||
@State private var chatViewModel = ChatViewModel()
|
||||
@State private var showAbout = false
|
||||
#if os(macOS)
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
#endif
|
||||
|
||||
init() {
|
||||
// Start email handler on app launch
|
||||
@@ -68,9 +106,6 @@ struct oAIApp: App {
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
||||
Task {
|
||||
await chatViewModel.onAppWillTerminate()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user