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
@@ -511,9 +511,22 @@
<p>From the <strong>File menu</strong> you also have:</p>
<ul>
<li><strong>Save Chat (<kbd>⌘S</kbd>)</strong> — Re-saves under the current name, or prompts for a name if the conversation hasn't been saved yet.</li>
<li><strong>Save Chat As…</strong> — Always prompts for a new name and creates a fresh copy, switching the session to that copy. Useful for branching a conversation.</li>
<li><strong>Save Chat (<kbd>⌘S</kbd>)</strong> — Re-saves under the current name, or prompts for a name and folder if the conversation hasn't been saved yet.</li>
<li><strong>Save Chat As…</strong> — Always prompts for a new name and folder and creates a fresh copy, switching the session to that copy. Useful for branching a conversation.</li>
</ul>
<p class="note">The Save dialog includes a folder picker — choose an existing folder or pick <strong>New Folder…</strong> to create one on the spot.</p>
<h3 id="unsaved-changes">Unsaved Changes &amp; Crash Recovery</h3>
<p>oAI tracks unsaved changes like a standard Mac document. Starting a New Chat, clearing the chat, loading a different conversation, or quitting the app while there are unsaved messages shows the standard save prompt:</p>
<ul>
<li><strong>Save</strong> — saves the conversation (prompting for a name and folder if it hasn't been saved yet), then proceeds</li>
<li><strong>Don't Save</strong> (<kbd>⌘D</kbd> in the prompt) — discards the changes and proceeds</li>
<li><strong>Cancel</strong> — stops the action so nothing is lost</li>
</ul>
<div class="tip">
<strong>💡 Crash Recovery:</strong> While you're chatting, oAI periodically mirrors the in-progress conversation to disk (every 10 seconds by default — adjustable or turned off in Settings → Advanced). If oAI crashes or is force-quit, the next launch offers to restore the conversation you were working on, including the model you had selected. This mirror is invisible and separate from your saved conversations — it's cleared automatically as soon as you save, discard, or restore it.
</div>
<h3>Renaming Conversations</h3>
<p>In the Conversations list (<kbd>⌘L</kbd>):</p>
@@ -605,27 +618,10 @@
<li>Click <strong>Clone Repository</strong> to initialize</li>
</ol>
<h3>Auto-Save Features</h3>
<p>When auto-save is enabled, oAI automatically saves and syncs conversations based on triggers:</p>
<h4>Auto-Save Triggers</h4>
<ul>
<li><strong>On App Start</strong> - Pulls and imports changes when oAI launches (no push)</li>
<li><strong>On Model Switch</strong> - Saves when you change AI models</li>
<li><strong>On App Quit</strong> - Saves before oAI closes</li>
<li><strong>After Idle Timeout</strong> - Saves after 5 seconds of inactivity</li>
</ul>
<h4>Auto-Save Settings</h4>
<ul>
<li><strong>Minimum Messages</strong> - Only save conversations with at least N messages (default: 5)</li>
<li><strong>Auto-Export</strong> - Export conversations to markdown after save</li>
<li><strong>Auto-Commit</strong> - Commit changes to git automatically</li>
<li><strong>Auto-Push</strong> - Push commits to remote repository</li>
</ul>
<p>Saving to Git is explicit rather than automatic — see <a href="#unsaved-changes">Unsaved Changes &amp; Crash Recovery</a> for how oAI tracks and prompts you to save. Every explicit save (<kbd>⌘S</kbd>, Save Chat As…, or the unsaved-changes prompt) triggers a background sync (export + commit + push) automatically when Git Sync is configured.</p>
<div class="warning">
<strong>⚠️ Multi-Machine Warning:</strong> Running auto-sync on multiple machines simultaneously can cause merge conflicts. Use auto-sync on your primary machine only, or manually sync on others.
<strong>⚠️ Multi-Machine Warning:</strong> Syncing on multiple machines simultaneously can cause merge conflicts. Pull before you start working on a different machine.
</div>
<h3>Manual Sync Operations</h3>
@@ -1544,11 +1540,17 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<dt><kbd>⌘M</kbd></dt>
<dd>Model Selector</dd>
<dt><kbd>⌘N</kbd></dt>
<dd>New Chat (prompts to save first if there are unsaved changes)</dd>
<dt><kbd>⌘K</kbd></dt>
<dd>Clear Chat</dd>
<dd>Clear Chat (prompts to save first if there are unsaved changes)</dd>
<dt><kbd>⌘O</kbd></dt>
<dd>Open Chat…</dd>
<dt><kbd>⌘S</kbd></dt>
<dd>Save Chat (re-saves if already named, prompts for name otherwise)</dd>
<dd>Save Chat (re-saves if already named, prompts for name and folder otherwise)</dd>
<dt><kbd>⇧⌘S</kbd></dt>
<dd>Show Statistics</dd>
@@ -1609,13 +1611,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<li><strong>Authentication Method</strong> - Choose SSH, Password, or Access Token</li>
<li><strong>Credentials</strong> - Enter username/password or access token (encrypted storage)</li>
<li><strong>Local Path</strong> - Where to clone the repository locally (default: ~/oAI-Sync)</li>
<li><strong>Auto-Save Settings</strong>:
<ul>
<li><strong>Enable Auto-Save</strong> - Automatically sync conversations</li>
<li><strong>Minimum Messages</strong> - Only sync conversations with at least N messages</li>
<li><strong>Triggers</strong> - Sync on app start, idle, goodbye phrases, model switch, or app quit</li>
</ul>
</li>
<li>Syncing happens automatically after every explicit save — no separate auto-save toggle needed. See <a href="#unsaved-changes">Unsaved Changes &amp; Crash Recovery</a>.</li>
<li><strong>Manual Sync</strong>:
<ul>
<li><strong>Initialize Repository</strong> - Clone repository for first-time setup</li>
@@ -1702,6 +1698,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
<li><strong>Smart Context Selection</strong> — Intelligently select relevant messages to reduce token usage</li>
<li><strong>Semantic Search</strong> — Enable AI-powered conversation search using embeddings</li>
<li><strong>Progressive Summarization</strong> — Automatically summarize old portions of long conversations</li>
<li><strong>Crash Recovery</strong> — how often the in-progress conversation is mirrored to disk (Off, 1s, 10s, 30s, 60s); see <a href="#unsaved-changes">Unsaved Changes &amp; Crash Recovery</a></li>
</ul>
<h3>Backup Tab</h3>
+77
View File
@@ -0,0 +1,77 @@
//
// DraftRecoveryService.swift
// oAI
//
// Crash-recovery draft for the in-progress conversation a lightweight,
// invisible mirror of the current chat, distinct from named saved conversations.
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use including selling oAI or any part of
// it, standalone or bundled into another product or service
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import Foundation
struct DraftConversation: Codable, Sendable {
let messages: [Message]
let conversationId: UUID?
let conversationName: String?
let modelId: String?
let savedAt: Date
}
final class DraftRecoveryService: Sendable {
static let shared = DraftRecoveryService()
private let fileURL: URL
/// - Parameter fileURL: injection point for tests; production uses the default
/// Application Support location, matching `DatabaseService`'s pattern.
nonisolated init(fileURL: URL? = nil) {
if let fileURL {
self.fileURL = fileURL
} else {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first!
let dir = appSupport.appendingPathComponent("oAI", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
self.fileURL = dir.appendingPathComponent("draft_conversation.json")
}
}
func save(_ draft: DraftConversation) {
do {
let data = try JSONEncoder().encode(draft)
try data.write(to: fileURL, options: .atomic)
Log.db.info("DraftRecoveryService: wrote draft to \(fileURL.path)")
} catch {
Log.db.error("DraftRecoveryService: save failed: \(error.localizedDescription)")
}
}
func load() -> DraftConversation? {
do {
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode(DraftConversation.self, from: data)
} catch {
Log.db.info("DraftRecoveryService: load found nothing (\(error.localizedDescription))")
return nil
}
}
func clear() {
try? FileManager.default.removeItem(at: fileURL)
}
}
+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))
}
}
+316 -245
View File
@@ -26,6 +26,90 @@ import os
import SwiftUI
import UniformTypeIdentifiers
#if os(macOS)
/// Backing object for the Save Chat accessory view's name field + folder picker. A plain
/// NSObject is needed here (rather than living on `ChatViewModel`) because `NSPopUpButton`/
/// `NSMenuItem` actions require an `@objc` target, and the `@Observable` `ChatViewModel` isn't one.
private final class ConversationSaveAccessory: NSObject {
let container: NSView
let nameField: NSTextField
private let folderPopup: NSPopUpButton
private var folders: [Folder]
private var lastGoodSelection: UUID? // the folder to revert to if "New Folder" is cancelled
init(defaultName: String, folders: [Folder], selectedFolderId: UUID?) {
self.folders = folders
self.lastGoodSelection = selectedFolderId
container = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 58))
nameField = NSTextField(frame: NSRect(x: 0, y: 30, width: 260, height: 24))
folderPopup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
super.init()
nameField.placeholderString = "Conversation name…"
nameField.stringValue = defaultName
container.addSubview(nameField)
rebuildMenu(selecting: selectedFolderId)
folderPopup.target = self
folderPopup.action = #selector(popupChanged)
container.addSubview(folderPopup)
}
private func rebuildMenu(selecting folderId: UUID?) {
folderPopup.removeAllItems()
folderPopup.addItem(withTitle: "No Folder")
for folder in folders {
folderPopup.addItem(withTitle: folder.name)
}
folderPopup.menu?.addItem(.separator())
folderPopup.addItem(withTitle: "New Folder…")
if let folderId, let idx = folders.firstIndex(where: { $0.id == folderId }) {
folderPopup.selectItem(at: idx + 1)
} else {
folderPopup.selectItem(at: 0)
}
}
@objc private func popupChanged() {
let lastIndex = folderPopup.numberOfItems - 1
guard folderPopup.indexOfSelectedItem == lastIndex else {
lastGoodSelection = resolvedFolderId
return
}
// "New Folder" was picked prompt inline, then create + select, or revert.
let nameAlert = NSAlert()
nameAlert.messageText = "New Folder"
nameAlert.informativeText = "Enter a name for the new folder:"
nameAlert.addButton(withTitle: "Create")
nameAlert.addButton(withTitle: "Cancel")
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
nameAlert.accessoryView = field
nameAlert.window.initialFirstResponder = field
guard nameAlert.runModal() == .alertFirstButtonReturn else {
rebuildMenu(selecting: lastGoodSelection)
return
}
let newName = field.stringValue.trimmingCharacters(in: .whitespaces)
guard !newName.isEmpty, let created = try? DatabaseService.shared.createFolder(name: newName) else {
rebuildMenu(selecting: lastGoodSelection)
return
}
folders.append(created)
rebuildMenu(selecting: created.id)
lastGoodSelection = .some(created.id)
}
var resolvedFolderId: UUID? {
let idx = folderPopup.indexOfSelectedItem
guard idx >= 1, idx - 1 < folders.count else { return nil }
return folders[idx - 1].id
}
}
#endif
@Observable
@MainActor
class ChatViewModel {
@@ -68,11 +152,11 @@ class ChatViewModel {
return chatCount > 0 && chatCount != savedMessageCount
}
// MARK: - Auto-Save Tracking
// MARK: - Crash-Recovery Draft
private var conversationStartTime: Date?
private var lastMessageTime: Date?
private var idleCheckTimer: Timer?
private var draftTimer: Timer?
private var lastDraftFingerprint: Int?
private var hasCheckedForCrashRecoveryDraft = false
// MARK: - Private State
@@ -230,6 +314,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
Task {
await loadAvailableModels()
}
startDraftTimer()
}
// MARK: - Public Methods
@@ -245,8 +331,16 @@ Don't narrate future actions ("Let me...") - just use the tools.
Task { await loadAvailableModels() }
}
/// Start a new conversation
/// Start a new conversation gated behind the unsaved-changes prompt if needed.
func newConversation() {
#if os(macOS)
confirmDiscardIfNeeded(then: performNewConversation)
#else
performNewConversation()
#endif
}
private func performNewConversation() {
messages = []
sessionStats = SessionStats()
inputText = ""
@@ -264,6 +358,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
memoryEnabled = settings.memoryEnabled
mcpEnabled = settings.mcpEnabled
mcpStatus = mcpEnabled ? "MCP" : nil
startDraftTimer()
if providerChanged {
selectedModel = nil
@@ -341,6 +436,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages.append(userMessage)
sessionStats.addMessage(inputTokens: userMessage.tokens, outputTokens: nil, cost: nil)
// Persist the crash-recovery draft immediately rather than waiting for the next
// periodic tick otherwise a force-quit shortly after sending loses this message.
persistDraftIfChanged()
// Generate embedding for user message
generateEmbeddingForMessage(userMessage)
@@ -352,11 +451,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Clear input
inputText = ""
// Check auto-save triggers in background
Task {
await checkAutoSaveTriggersAfterMessage(cleanText)
}
// Generate real AI response
generateAIResponse(to: cleanText, attachments: userMessage.attachments)
}
@@ -375,14 +469,32 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
}
/// Clear the current chat gated behind the unsaved-changes prompt if needed.
func clearChat() {
#if os(macOS)
confirmDiscardIfNeeded(then: performClearChat)
#else
performClearChat()
#endif
}
private func performClearChat() {
messages.removeAll()
sessionStats.reset()
MCPService.shared.resetBashSessionApproval()
showSystemMessage("Chat cleared")
}
/// Load a saved conversation gated behind the unsaved-changes prompt if needed.
func loadConversation(_ conversation: Conversation) {
#if os(macOS)
confirmDiscardIfNeeded(then: { [weak self] in self?.performLoadConversation(conversation) })
#else
performLoadConversation(conversation)
#endif
}
private func performLoadConversation(_ conversation: Conversation) {
do {
guard let (_, loadedMessages) = try DatabaseService.shared.loadConversation(id: conversation.id) else {
showSystemMessage("Could not load conversation '\(conversation.name)'")
@@ -528,72 +640,57 @@ Don't narrate future actions ("Let me...") - just use the tools.
// MARK: - Quick Save
/// Called from the File menu re-saves if already named, shows NSAlert to name if not.
/// Called from the File menu re-saves if already named, prompts for name + folder if not.
func saveFromMenu() {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else { return }
if currentConversationName != nil {
quickSave()
} else {
#if os(macOS)
let alert = NSAlert()
alert.messageText = "Save Chat"
alert.informativeText = "Enter a name for this conversation:"
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
input.placeholderString = "Conversation name…"
alert.accessoryView = input
alert.window.initialFirstResponder = input
guard alert.runModal() == .alertFirstButtonReturn else { return }
let name = input.stringValue.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
do {
let saved = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages)
currentConversationId = saved.id
currentConversationName = name
savedMessageCount = chatMessages.count
showSystemMessage("Saved as \"\(name)\"")
} catch {
showSystemMessage("Save failed: \(error.localizedDescription)")
}
#endif
}
#if os(macOS)
attemptSaveCurrentConversation()
#endif
}
/// Always prompts for a new name and saves a fresh copy, switching the session to that copy.
/// Always prompts for a new name (and folder) and saves a fresh copy, switching the session to that copy.
func saveAsFromMenu() {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else { return }
#if os(macOS)
let alert = NSAlert()
alert.messageText = "Save Chat As"
alert.informativeText = "Enter a name for this conversation:"
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
input.placeholderString = "Conversation name…"
if let existing = currentConversationName {
input.stringValue = existing // pre-fill with current name as a starting point
}
alert.accessoryView = input
alert.window.initialFirstResponder = input
guard alert.runModal() == .alertFirstButtonReturn else { return }
let name = input.stringValue.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
guard let details = promptForConversationDetails(title: "Save Chat As", defaultName: currentConversationName ?? "") else { return }
do {
let saved = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages)
let saved = try DatabaseService.shared.saveConversation(
id: UUID(), name: details.name, messages: chatMessages,
primaryModel: selectedModel?.id, folderId: details.folderId
)
currentConversationId = saved.id
currentConversationName = name
currentConversationName = details.name
savedMessageCount = chatMessages.count
showSystemMessage("Saved as \"\(name)\"")
showSystemMessage("Saved as \"\(details.name)\"")
Task { await GitSyncService.shared.autoSync() }
} catch {
showSystemMessage("Save failed: \(error.localizedDescription)")
}
#endif
}
#if os(macOS)
/// Shows the "Save Chat" alert with a name field and folder picker (including inline
/// "New Folder" creation). Returns nil if the user cancels or leaves the name empty.
private func promptForConversationDetails(title: String = "Save Chat", defaultName: String) -> (name: String, folderId: UUID?)? {
let folders = (try? DatabaseService.shared.listFolders()) ?? []
let accessory = ConversationSaveAccessory(defaultName: defaultName, folders: folders, selectedFolderId: nil)
let alert = NSAlert()
alert.messageText = title
alert.informativeText = "Enter a name for this conversation:"
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
alert.accessoryView = accessory.container
alert.window.initialFirstResponder = accessory.nameField
guard alert.runModal() == .alertFirstButtonReturn else { return nil }
let name = accessory.nameField.stringValue.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return nil }
return (name, accessory.resolvedFolderId)
}
#endif
/// Called by ConversationListView after renaming a saved conversation.
/// Updates the in-session name if the renamed conversation is currently open.
func didRenameConversation(id: UUID, newName: String) {
@@ -1318,7 +1415,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
_ = Self.detectGoodbyePhrase(in: "")
} catch {
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = "❌ Image generation failed: \(error.localizedDescription)"
@@ -1963,214 +2059,189 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
}
/// Pure auto-save eligibility check, pulled out of `shouldAutoSave()` so the criteria
/// (enabled, configured, cloned, min message count, not-already-saved) can be tested
/// without a live ChatViewModel/GitSyncService/SettingsService.
nonisolated static func shouldAutoSave(
syncEnabled: Bool,
syncAutoSave: Bool,
syncConfigured: Bool,
isCloned: Bool,
chatMessageCount: Int,
minMessages: Int,
lastSavedConversationId: String?,
currentConversationHash: String
) -> Bool {
guard syncEnabled && syncAutoSave else { return false }
guard syncConfigured else { return false }
guard isCloned else { return false }
guard chatMessageCount >= minMessages else { return false }
if let lastSavedId = lastSavedConversationId, lastSavedId == currentConversationHash {
return false // Already saved this exact conversation
}
return true
// MARK: - Crash-Recovery Draft
/// Pure fingerprint of a message list's content, used to skip rewriting the crash-recovery
/// draft to disk when nothing has actually changed. Not stable across process launches
/// (String hashing is randomized per-run) only ever compared within a single session.
nonisolated static func draftFingerprint(for messages: [Message]) -> Int {
messages.map { "\($0.role.rawValue)|\($0.content)" }.joined(separator: "\u{1}").hashValue
}
/// Check if conversation should be auto-saved based on criteria
func shouldAutoSave() -> Bool {
let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
let currentHash = chatMessages.map { $0.content }.joined()
return Self.shouldAutoSave(
syncEnabled: settings.syncEnabled,
syncAutoSave: settings.syncAutoSave,
syncConfigured: settings.syncConfigured,
isCloned: GitSyncService.shared.syncStatus.isCloned,
chatMessageCount: chatMessages.count,
minMessages: settings.syncAutoSaveMinMessages,
lastSavedConversationId: settings.syncLastAutoSaveConversationId,
currentConversationHash: currentHash
)
}
/// (Re)schedules the periodic draft-persistence timer using the current
/// `settings.draftRecoveryIntervalSeconds`. Call again after Settings changes it.
/// A value of 0 disables the periodic tick (and clears any existing draft).
func startDraftTimer() {
draftTimer?.invalidate()
draftTimer = nil
/// Auto-save the current conversation with background summarization
func autoSaveConversation() async {
guard shouldAutoSave() else {
let interval = settings.draftRecoveryIntervalSeconds
guard interval > 0 else {
DraftRecoveryService.shared.clear()
return
}
Log.ui.info("Auto-saving conversation...")
// Get summary in background (hidden from user)
let summary = await summarizeConversationInBackground()
// Use summary as name, or fallback to timestamp
let conversationName: String
if let summary = summary, !summary.isEmpty {
conversationName = summary
} else {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm"
conversationName = "Conversation - \(formatter.string(from: Date()))"
}
// Save the conversation
do {
let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
let conversation = try DatabaseService.shared.saveConversation(
name: conversationName,
messages: chatMessages
)
currentConversationId = conversation.id
currentConversationName = conversationName
savedMessageCount = chatMessages.count
Log.ui.info("Auto-saved conversation: \(conversationName)")
// Check if progressive summarization is needed
Task {
await checkAndSummarizeOldMessages(conversationId: conversation.id)
}
// Generate embeddings for messages that don't have them yet.
// Run sequentially at background priority so this never blocks the chat.
if settings.embeddingsEnabled {
Task(priority: .background) {
guard let provider = EmbeddingService.shared.getSelectedProvider() else { return }
for message in chatMessages {
await embedMessage(message, provider: provider)
// Yield briefly between requests to avoid bursting the API
try? await Task.sleep(for: .milliseconds(150))
}
}
}
// Mark as saved to prevent duplicate saves
let conversationHash = chatMessages.map { $0.content }.joined()
settings.syncLastAutoSaveConversationId = conversationHash
// Trigger auto-sync (export + push)
Task {
await performAutoSync()
}
} catch {
Log.ui.error("Auto-save failed: \(error.localizedDescription)")
}
}
/// Perform auto-sync: export + push to git (debounced)
private func performAutoSync() async {
await GitSyncService.shared.autoSync()
}
// MARK: - Smart Triggers
/// Update conversation tracking times
func updateConversationTracking() {
let now = Date()
if conversationStartTime == nil {
conversationStartTime = now
}
lastMessageTime = now
// Restart idle timer if enabled
if settings.syncAutoSaveOnIdle {
startIdleTimer()
}
}
/// Start or restart the idle timer
private func startIdleTimer() {
// Cancel existing timer
idleCheckTimer?.invalidate()
let idleMinutes = settings.syncAutoSaveIdleMinutes
let idleSeconds = TimeInterval(idleMinutes * 60)
// Schedule new timer
idleCheckTimer = Timer.scheduledTimer(withTimeInterval: idleSeconds, repeats: false) { [weak self] _ in
draftTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(interval), repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
await self?.onIdleTimeout()
self?.persistDraftIfChanged()
}
}
}
/// Called when idle timeout is reached
private func onIdleTimeout() async {
guard settings.syncAutoSaveOnIdle else { return }
private func persistDraftIfChanged() {
guard settings.draftRecoveryIntervalSeconds > 0 else { return }
Log.ui.info("Idle timeout reached - triggering auto-save")
await autoSaveConversation()
let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
guard !chatMessages.isEmpty else { return }
let fingerprint = Self.draftFingerprint(for: chatMessages)
guard fingerprint != lastDraftFingerprint else { return }
lastDraftFingerprint = fingerprint
DraftRecoveryService.shared.save(DraftConversation(
messages: chatMessages,
conversationId: currentConversationId,
conversationName: currentConversationName,
modelId: selectedModel?.id,
savedAt: Date()
))
}
/// Detect goodbye phrases in user message
nonisolated static func detectGoodbyePhrase(in text: String) -> Bool {
let lowercased = text.lowercased()
let goodbyePhrases = [
"bye", "goodbye", "bye bye", "good bye",
"that's all", "thats all", "that'll be all",
"i'm done", "we're done",
"see you", "see ya", "catch you later",
"have a good day", "have a nice day"
]
/// Called once on launch from `ContentView.onAppear` in `ContentView.swift`, deferred one
/// run-loop tick via `DispatchQueue.main.async` so the modal alert reliably presents calling
/// it directly and undeferred from `.onAppear` was tried and failed silently. Previously called
/// from `AppDelegate.applicationDidFinishLaunching`, but that read a throwaway `ChatViewModel`
/// instance from `oAIApp.init()`'s own `@State` rather than the one actually rendered (confirmed
/// via ObjectIdentifier logging restore appeared to work but never touched the visible chat).
/// Offers to restore a conversation left behind by a crash or force-quit. A no-op after a clean
/// shutdown, since `confirmDiscardIfNeeded` always clears the draft before New Chat/Clear/Switch/Quit proceed.
func checkForCrashRecoveryDraft() {
guard !hasCheckedForCrashRecoveryDraft else { return }
hasCheckedForCrashRecoveryDraft = true
return goodbyePhrases.contains { phrase in
// Check for whole word match (not substring)
let pattern = "\\b\(NSRegularExpression.escapedPattern(for: phrase))\\b"
return lowercased.range(of: pattern, options: .regularExpression) != nil
}
}
// oAITests is app-hosted, so `xcodebuild test` launches this same app as the test host
// without this guard, a leftover draft file on disk (e.g. from manual kill-9 testing)
// makes the test host hit this exact blocking NSAlert.runModal() with no one there to
// click it, hanging the entire test run indefinitely.
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return }
/// Trigger auto-save when user switches models
func onModelSwitch(from oldModel: ModelInfo?, to newModel: ModelInfo?) async {
guard settings.syncAutoSaveOnModelSwitch else { return }
guard oldModel != nil else { return } // Don't save on first model selection
let loaded = DraftRecoveryService.shared.load()
Log.ui.info("checkForCrashRecoveryDraft: found draft = \(loaded != nil), messageCount = \(loaded?.messages.count ?? -1)")
guard let draft = loaded, !draft.messages.isEmpty else { return }
Log.ui.info("Model switch detected - triggering auto-save")
await autoSaveConversation()
}
#if os(macOS)
// Bring the app frontmost first this runs one tick after launch, before the app has
// necessarily activated, and an app-modal alert shown to a non-active app can end up
// behind other windows (clicks land on whatever's actually frontmost, not the alert).
NSApp.activate(ignoringOtherApps: true)
/// Trigger auto-save on app quit
func onAppWillTerminate() async {
guard settings.syncAutoSaveOnAppQuit else { return }
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = "Restore unsaved conversation?"
alert.informativeText = "oAI didn't close properly last time. Would you like to restore the conversation you were working on?"
alert.addButton(withTitle: "Restore")
alert.addButton(withTitle: "Discard")
Log.ui.info("App quit detected - triggering auto-save")
await autoSaveConversation()
}
/// Check and trigger auto-save after user message
func checkAutoSaveTriggersAfterMessage(_ text: String) async {
// Update tracking
updateConversationTracking()
// Check for goodbye phrase
if Self.detectGoodbyePhrase(in: text) {
Log.ui.info("Goodbye phrase detected - triggering auto-save")
// Wait a bit to see if user continues
try? await Task.sleep(for: .seconds(30))
// Check if they sent another message in the meantime
if let lastTime = lastMessageTime, Date().timeIntervalSince(lastTime) < 25 {
Log.ui.info("User continued chatting - skipping goodbye auto-save")
return
if alert.runModal() == .alertFirstButtonReturn {
messages = draft.messages
currentConversationId = draft.conversationId
currentConversationName = draft.conversationName
savedMessageCount = 0 // always treat a restored draft as unsaved
// Clear the on-disk draft now that it's loaded into memory otherwise this exact
// file lingers forever (a clean quit only clears it via confirmDiscardIfNeeded, which
// never runs if the restored session is then quit before any new message is sent),
// and the same restore prompt reappears on every future launch no matter what the
// user picks here.
DraftRecoveryService.shared.clear()
showSystemMessage("Restored previous session (^[\(draft.messages.count) message](inflect: true))")
if let modelId = draft.modelId {
Task { await switchToConversationModel(modelId) }
}
} else {
DraftRecoveryService.shared.clear()
}
#endif
}
await autoSaveConversation()
// MARK: - Unsaved Changes Gate
#if os(macOS)
/// Standard macOS "unsaved changes" gate. If there are no unsaved changes, `proceed()` runs
/// immediately. Otherwise shows a Save / Don't Save / Cancel alert: Save attempts an explicit
/// save (prompting for name/folder if never named) and only proceeds on success; Don't Save
/// discards the crash-recovery draft and proceeds; Cancel calls `onCancel()` the caller's
/// original action (new chat / clear / switch / quit) must not happen.
func confirmDiscardIfNeeded(then proceed: @escaping () -> Void, onCancel: @escaping () -> Void = {}) {
guard hasUnsavedChanges else { proceed(); return }
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Do you want to save the changes you made to \"\(currentConversationName ?? "Untitled Conversation")\"?"
alert.informativeText = "Your changes will be lost if you don't save them."
alert.addButton(withTitle: "Save")
let dontSave = alert.addButton(withTitle: "Don't Save")
dontSave.keyEquivalent = "d"
dontSave.keyEquivalentModifierMask = .command
alert.addButton(withTitle: "Cancel")
switch alert.runModal() {
case .alertFirstButtonReturn:
if attemptSaveCurrentConversation() {
DraftRecoveryService.shared.clear()
proceed()
} else {
onCancel()
}
case .alertSecondButtonReturn:
DraftRecoveryService.shared.clear()
proceed()
default:
onCancel()
}
}
/// Saves the current conversation: silently re-saves in place if already named, otherwise
/// prompts for a name (and optional folder) via `promptForConversationDetails`. Returns true
/// on success (or if there was nothing to save), false if cancelled or the save failed.
@discardableResult
private func attemptSaveCurrentConversation() -> Bool {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else { return true }
if let id = currentConversationId, let name = currentConversationName {
do {
try DatabaseService.shared.updateConversation(
id: id, name: name, messages: chatMessages, primaryModel: selectedModel?.id
)
savedMessageCount = chatMessages.count
showSystemMessage("Saved \"\(name)\"")
Task { await GitSyncService.shared.autoSync() }
return true
} catch {
showSystemMessage("Save failed: \(error.localizedDescription)")
return false
}
}
guard let details = promptForConversationDetails(defaultName: "") else { return false }
do {
let saved = try DatabaseService.shared.saveConversation(
id: UUID(), name: details.name, messages: chatMessages,
primaryModel: selectedModel?.id, folderId: details.folderId
)
currentConversationId = saved.id
currentConversationName = details.name
savedMessageCount = chatMessages.count
showSystemMessage("Saved as \"\(details.name)\"")
Task { await GitSyncService.shared.autoSync() }
return true
} catch {
showSystemMessage("Save failed: \(error.localizedDescription)")
return false
}
}
#endif
// MARK: - Embedding Generation
/// Generate embedding for a single message (awaitable, no Task spawned).
+15 -4
View File
@@ -54,6 +54,21 @@ struct ContentView: View {
.onAppear {
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
checkIntelWarning()
// Wire the real, environment-injected chatViewModel into the app delegate for Quit
// interception `oAIApp.init()` used to do this by reading its own `@State`, but
// that returned a throwaway instance distinct from the one actually rendered here
// (confirmed via ObjectIdentifier logging). Deferred one run-loop tick via
// `DispatchQueue.main.async` so the modal alert reliably presents calling it
// directly from `.onAppear` was tried before and silently failed to ever show it.
// Uses `AppDelegate.shared`, NOT `NSApplication.shared.delegate as? AppDelegate`
// the latter always fails since `@NSApplicationDelegateAdaptor` registers an internal
// `SwiftUI.AppDelegate` wrapper as the real `NSApp.delegate`, a same-named-but-different
// type (confirmed via logging).
AppDelegate.shared?.chatViewModel = chatViewModel
DispatchQueue.main.async {
chatViewModel.checkForCrashRecoveryDraft()
}
}
.onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.command) {
@@ -68,12 +83,8 @@ struct ContentView: View {
models: chatViewModel.availableModels,
selectedModel: chatViewModel.selectedModel,
onSelect: { model in
let oldModel = chatViewModel.selectedModel
chatViewModel.selectModel(model)
chatViewModel.showModelSelector = false
Task {
await chatViewModel.onModelSwitch(from: oldModel, to: model)
}
}
)
.task {
+2 -2
View File
@@ -69,7 +69,7 @@ struct FooterView: View {
)
// Git sync status (if enabled)
if SettingsService.shared.syncEnabled && SettingsService.shared.syncAutoSave {
if SettingsService.shared.syncEnabled {
SyncStatusFooter()
}
}
@@ -85,7 +85,7 @@ struct FooterView: View {
if mcpEnabled {
StatusPill(icon: "folder", label: "MCP", color: .blue)
}
if settings.syncEnabled && settings.syncAutoSave {
if settings.syncEnabled {
SyncStatusPill()
}
}
-3
View File
@@ -140,9 +140,6 @@ struct SyncStatusIndicator: View {
.onChange(of: settings.syncEnabled) {
updateState()
}
.onChange(of: settings.syncAutoSave) {
updateState()
}
}
private var statusIcon: some View {
+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")
+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
}