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
+25 -66
View File
@@ -403,6 +403,31 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// Crash Recovery
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Crash Recovery")
formSection {
row("Save Draft Every") {
Picker("", selection: $settingsService.draftRecoveryIntervalSeconds) {
Text("Off").tag(0)
Text("1 second").tag(1)
Text("10 seconds").tag(10)
Text("30 seconds").tag(30)
Text("60 seconds").tag(60)
}
.labelsHidden()
.fixedSize()
}
VStack(alignment: .leading, spacing: 2) {
Text("Mirrors your in-progress conversation to disk so a crash or force-quit doesn't lose it. Never shown as a saved conversation.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
}
// Web Search
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Web Search")
@@ -1697,72 +1722,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// Auto-Save
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Auto-Save")
formSection {
row("Enable Auto-Save") {
Toggle("", isOn: $settingsService.syncAutoSave)
.toggleStyle(.switch)
}
if settingsService.syncAutoSave {
rowDivider()
row("Min Messages") {
HStack {
Slider(value: Binding(
get: { Double(settingsService.syncAutoSaveMinMessages) },
set: { settingsService.syncAutoSaveMinMessages = Int($0) }
), in: 3...20, step: 1)
.frame(width: 200)
Text("\(settingsService.syncAutoSaveMinMessages)")
.font(.system(size: 14))
.frame(width: 30)
}
}
rowDivider()
row("On model switch") {
Toggle("", isOn: $settingsService.syncAutoSaveOnModelSwitch)
.toggleStyle(.switch)
}
rowDivider()
row("On app quit") {
Toggle("", isOn: $settingsService.syncAutoSaveOnAppQuit)
.toggleStyle(.switch)
}
rowDivider()
row("After idle timeout") {
Toggle("", isOn: $settingsService.syncAutoSaveOnIdle)
.toggleStyle(.switch)
}
if settingsService.syncAutoSaveOnIdle {
rowDivider()
row("Idle Timeout") {
HStack {
Slider(value: Binding(
get: { Double(settingsService.syncAutoSaveIdleMinutes) },
set: { settingsService.syncAutoSaveIdleMinutes = Int($0) }
), in: 1...30, step: 1)
.frame(width: 200)
Text("\(settingsService.syncAutoSaveIdleMinutes) min")
.font(.system(size: 14))
.frame(width: 60)
}
}
}
}
}
}
if settingsService.syncAutoSave {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Auto-sync can cause conflicts if running on multiple machines simultaneously.")
.font(.system(size: 13))
.foregroundStyle(.orange)
}
.padding(.horizontal, 4)
}
// Manual Sync
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Manual Sync")