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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user