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:
2026-07-31 08:11:12 +02:00
parent a306aaef9a
commit e3557a87df
12 changed files with 585 additions and 490 deletions
+38 -3
View File
@@ -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
}