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
+7 -58
View File
@@ -1005,66 +1005,15 @@ class SettingsService {
}
}
// MARK: - Auto-Sync Settings
// MARK: - Crash-Recovery Draft Settings
var syncAutoSave: Bool {
get { cache["syncAutoSave"] == "true" }
/// How often (in seconds) the in-progress conversation is mirrored to a local
/// crash-recovery draft, so a force-quit/crash doesn't lose it. 0 = off.
var draftRecoveryIntervalSeconds: Int {
get { cache["draftRecoveryIntervalSeconds"].flatMap(Int.init) ?? 10 }
set {
cache["syncAutoSave"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoSave", value: String(newValue))
}
}
var syncAutoSaveMinMessages: Int {
get { cache["syncAutoSaveMinMessages"].flatMap(Int.init) ?? 5 }
set {
cache["syncAutoSaveMinMessages"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoSaveMinMessages", value: String(newValue))
}
}
var syncAutoSaveOnModelSwitch: Bool {
get { cache["syncAutoSaveOnModelSwitch"] == "true" }
set {
cache["syncAutoSaveOnModelSwitch"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoSaveOnModelSwitch", value: String(newValue))
}
}
var syncAutoSaveOnAppQuit: Bool {
get { cache["syncAutoSaveOnAppQuit"] == "true" }
set {
cache["syncAutoSaveOnAppQuit"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoSaveOnAppQuit", value: String(newValue))
}
}
var syncAutoSaveOnIdle: Bool {
get { cache["syncAutoSaveOnIdle"] == "true" }
set {
cache["syncAutoSaveOnIdle"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoSaveOnIdle", value: String(newValue))
}
}
var syncAutoSaveIdleMinutes: Int {
get { cache["syncAutoSaveIdleMinutes"].flatMap(Int.init) ?? 5 }
set {
cache["syncAutoSaveIdleMinutes"] = String(newValue)
DatabaseService.shared.setSetting(key: "syncAutoSaveIdleMinutes", value: String(newValue))
}
}
var syncLastAutoSaveConversationId: String? {
get { cache["syncLastAutoSaveConversationId"] }
set {
if let value = newValue {
cache["syncLastAutoSaveConversationId"] = value
DatabaseService.shared.setSetting(key: "syncLastAutoSaveConversationId", value: value)
} else {
cache.removeValue(forKey: "syncLastAutoSaveConversationId")
DatabaseService.shared.deleteSetting(key: "syncLastAutoSaveConversationId")
}
cache["draftRecoveryIntervalSeconds"] = String(newValue)
DatabaseService.shared.setSetting(key: "draftRecoveryIntervalSeconds", value: String(newValue))
}
}