diff --git a/README.md b/README.md
index 99a599d..a7ea2cd 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,8 @@ A powerful native macOS AI chat application with support for multiple providers
### 💬 Core Chat Capabilities
- **Streaming Responses** - Real-time token streaming for faster interactions
-- **Conversation Management** - Save, load, export, and search conversations
+- **Conversation Management** - Save, load, export, and search conversations, organized into folders
+- **Unsaved Changes & Crash Recovery** - Standard Mac-style save prompt on New Chat/Clear/Load/Quit with unsaved messages; the in-progress conversation (and selected model) is also mirrored to disk periodically and offered back on next launch if oAI crashes or is force-quit
- **Combine Conversations** - Merge 2+ saved conversations, either by chronological concatenation or AI-assisted synthesis
- **File Attachments** - Support for text files, images, and PDFs
- **Image Generation** - Create images with supported models (DALL-E, Flux, etc.) - renders inline in chat
@@ -169,12 +170,12 @@ Add your API keys in Settings (⌘,) → General tab:
- **Smart Context Selection** - Reduce token usage automatically
- **Semantic Search** - Enable AI-powered conversation search
- **Progressive Summarization** - Handle long conversations efficiently
+- **Crash Recovery** - How often the in-progress conversation is mirrored to disk (Off/1s/10s/30s/60s), so a crash or force-quit doesn't lose it
#### Sync Tab
- **Repository URL** - Git repository for conversation backup
- **Authentication** - Username/password or access token
-- **Auto-Save** - Configure automatic save triggers
-- **Manual Sync** - One-click synchronization
+- **Manual Sync** - One-click synchronization; every explicit save (⌘S, Save As, or the unsaved-changes prompt) also triggers a background sync automatically
#### Email Tab
- **Email Handler** - Configure automated email responses
diff --git a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html
index 6cd21e3..9431ae9 100644
--- a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html
+++ b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html
@@ -511,9 +511,22 @@
From the File menu you also have:
- - Save Chat (⌘S) — Re-saves under the current name, or prompts for a name if the conversation hasn't been saved yet.
- - Save Chat As… — Always prompts for a new name and creates a fresh copy, switching the session to that copy. Useful for branching a conversation.
+ - Save Chat (⌘S) — Re-saves under the current name, or prompts for a name and folder if the conversation hasn't been saved yet.
+ - Save Chat As… — Always prompts for a new name and folder and creates a fresh copy, switching the session to that copy. Useful for branching a conversation.
+ The Save dialog includes a folder picker — choose an existing folder or pick New Folder… to create one on the spot.
+
+ Unsaved Changes & Crash Recovery
+ 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:
+
+ - Save — saves the conversation (prompting for a name and folder if it hasn't been saved yet), then proceeds
+ - Don't Save (⌘D in the prompt) — discards the changes and proceeds
+ - Cancel — stops the action so nothing is lost
+
+
+
+ 💡 Crash Recovery: 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.
+
Renaming Conversations
In the Conversations list (⌘L):
@@ -605,27 +618,10 @@
Click Clone Repository to initialize
- Auto-Save Features
- When auto-save is enabled, oAI automatically saves and syncs conversations based on triggers:
-
- Auto-Save Triggers
-
- - On App Start - Pulls and imports changes when oAI launches (no push)
- - On Model Switch - Saves when you change AI models
- - On App Quit - Saves before oAI closes
- - After Idle Timeout - Saves after 5 seconds of inactivity
-
-
- Auto-Save Settings
-
- - Minimum Messages - Only save conversations with at least N messages (default: 5)
- - Auto-Export - Export conversations to markdown after save
- - Auto-Commit - Commit changes to git automatically
- - Auto-Push - Push commits to remote repository
-
+ Saving to Git is explicit rather than automatic — see Unsaved Changes & Crash Recovery for how oAI tracks and prompts you to save. Every explicit save (⌘S, Save Chat As…, or the unsaved-changes prompt) triggers a background sync (export + commit + push) automatically when Git Sync is configured.
- ⚠️ Multi-Machine Warning: Running auto-sync on multiple machines simultaneously can cause merge conflicts. Use auto-sync on your primary machine only, or manually sync on others.
+ ⚠️ Multi-Machine Warning: Syncing on multiple machines simultaneously can cause merge conflicts. Pull before you start working on a different machine.
Manual Sync Operations
@@ -1544,11 +1540,17 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
⌘M
Model Selector
+ ⌘N
+ New Chat (prompts to save first if there are unsaved changes)
+
⌘K
- Clear Chat
+ Clear Chat (prompts to save first if there are unsaved changes)
+
+ ⌘O
+ Open Chat…
⌘S
- Save Chat (re-saves if already named, prompts for name otherwise)
+ Save Chat (re-saves if already named, prompts for name and folder otherwise)
⇧⌘S
Show Statistics
@@ -1609,13 +1611,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
Authentication Method - Choose SSH, Password, or Access Token
Credentials - Enter username/password or access token (encrypted storage)
Local Path - Where to clone the repository locally (default: ~/oAI-Sync)
- Auto-Save Settings:
-
- - Enable Auto-Save - Automatically sync conversations
- - Minimum Messages - Only sync conversations with at least N messages
- - Triggers - Sync on app start, idle, goodbye phrases, model switch, or app quit
-
-
+ Syncing happens automatically after every explicit save — no separate auto-save toggle needed. See Unsaved Changes & Crash Recovery.
Manual Sync:
- Initialize Repository - Clone repository for first-time setup
@@ -1702,6 +1698,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
- Smart Context Selection — Intelligently select relevant messages to reduce token usage
- Semantic Search — Enable AI-powered conversation search using embeddings
- Progressive Summarization — Automatically summarize old portions of long conversations
+ - Crash Recovery — how often the in-progress conversation is mirrored to disk (Off, 1s, 10s, 30s, 60s); see Unsaved Changes & Crash Recovery
Backup Tab
diff --git a/oAI/Services/DraftRecoveryService.swift b/oAI/Services/DraftRecoveryService.swift
new file mode 100644
index 0000000..f71910a
--- /dev/null
+++ b/oAI/Services/DraftRecoveryService.swift
@@ -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
+// for
+// the full license text. For commercial licensing, contact Rune
+// Olsen via .
+
+
+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)
+ }
+}
diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift
index 7c2b1ee..cfada45 100644
--- a/oAI/Services/SettingsService.swift
+++ b/oAI/Services/SettingsService.swift
@@ -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))
}
}
diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift
index c26dc90..37f64ea 100644
--- a/oAI/ViewModels/ChatViewModel.swift
+++ b/oAI/ViewModels/ChatViewModel.swift
@@ -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).
diff --git a/oAI/Views/Main/ContentView.swift b/oAI/Views/Main/ContentView.swift
index f2d740b..b3d88d2 100644
--- a/oAI/Views/Main/ContentView.swift
+++ b/oAI/Views/Main/ContentView.swift
@@ -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 {
diff --git a/oAI/Views/Main/FooterView.swift b/oAI/Views/Main/FooterView.swift
index 2007e8a..819f80e 100644
--- a/oAI/Views/Main/FooterView.swift
+++ b/oAI/Views/Main/FooterView.swift
@@ -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()
}
}
diff --git a/oAI/Views/Main/SyncStatusIndicator.swift b/oAI/Views/Main/SyncStatusIndicator.swift
index aeca3cc..433f560 100644
--- a/oAI/Views/Main/SyncStatusIndicator.swift
+++ b/oAI/Views/Main/SyncStatusIndicator.swift
@@ -140,9 +140,6 @@ struct SyncStatusIndicator: View {
.onChange(of: settings.syncEnabled) {
updateState()
}
- .onChange(of: settings.syncAutoSave) {
- updateState()
- }
}
private var statusIcon: some View {
diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift
index b9479d9..43ed5f1 100644
--- a/oAI/Views/Screens/SettingsView.swift
+++ b/oAI/Views/Screens/SettingsView.swift
@@ -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")
diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift
index ac24d0e..6e2fd0e 100644
--- a/oAI/oAIApp.swift
+++ b/oAI/oAIApp.swift
@@ -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()"). 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
}
diff --git a/oAITests/ChatViewModelPureLogicTests.swift b/oAITests/ChatViewModelPureLogicTests.swift
index 36e4a5d..4ea0d46 100644
--- a/oAITests/ChatViewModelPureLogicTests.swift
+++ b/oAITests/ChatViewModelPureLogicTests.swift
@@ -11,33 +11,6 @@ import Testing
@Suite("ChatViewModel pure static helpers")
struct ChatViewModelPureLogicTests {
- // MARK: - detectGoodbyePhrase
-
- @Test("Recognizes a clear farewell phrase")
- func detectsGoodbye() {
- #expect(ChatViewModel.detectGoodbyePhrase(in: "OK, bye!"))
- #expect(ChatViewModel.detectGoodbyePhrase(in: "That's all, thanks."))
- #expect(ChatViewModel.detectGoodbyePhrase(in: "Have a good day"))
- }
-
- @Test("Does not false-positive on a word that merely contains a phrase as a substring")
- func doesNotMatchSubstring() {
- // "bye" is a whole-word match target -- "goodbyeee" should not match "bye"
- // as a substring because of the \b word-boundary regex.
- #expect(!ChatViewModel.detectGoodbyePhrase(in: "goodbyeee is not a real word"))
- }
-
- @Test("Does not match ordinary polite phrases that aren't farewells")
- func doesNotMatchPoliteRequests() {
- #expect(!ChatViewModel.detectGoodbyePhrase(in: "Thanks, that's helpful!"))
- #expect(!ChatViewModel.detectGoodbyePhrase(in: "Done with step one, what's next?"))
- }
-
- @Test("Matching is case-insensitive")
- func matchIsCaseInsensitive() {
- #expect(ChatViewModel.detectGoodbyePhrase(in: "BYE BYE"))
- }
-
// MARK: - inferProvider
@Test("Model ID with a slash is inferred as OpenRouter")
@@ -109,59 +82,26 @@ struct ChatViewModelPureLogicTests {
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.0)
}
- // MARK: - shouldAutoSave
+ // MARK: - draftFingerprint
- private func autoSaveEligible(
- syncEnabled: Bool = true,
- syncAutoSave: Bool = true,
- syncConfigured: Bool = true,
- isCloned: Bool = true,
- chatMessageCount: Int = 10,
- minMessages: Int = 4,
- lastSavedConversationId: String? = nil,
- currentConversationHash: String = "hash-a"
- ) -> Bool {
- ChatViewModel.shouldAutoSave(
- syncEnabled: syncEnabled,
- syncAutoSave: syncAutoSave,
- syncConfigured: syncConfigured,
- isCloned: isCloned,
- chatMessageCount: chatMessageCount,
- minMessages: minMessages,
- lastSavedConversationId: lastSavedConversationId,
- currentConversationHash: currentConversationHash
- )
+ @Test("Identical message content produces the same fingerprint")
+ func draftFingerprintStableForSameContent() {
+ let messages = [
+ Message(role: .user, content: "Hello there"),
+ Message(role: .assistant, content: "Hi! How can I help?")
+ ]
+ #expect(ChatViewModel.draftFingerprint(for: messages) == ChatViewModel.draftFingerprint(for: messages))
}
- @Test("Eligible when every criterion is satisfied")
- func autoSaveEligibleWhenAllCriteriaMet() {
- #expect(autoSaveEligible())
+ @Test("Changed message content produces a different fingerprint")
+ func draftFingerprintChangesWithContent() {
+ let before = [Message(role: .user, content: "Hello there")]
+ let after = [Message(role: .user, content: "Hello there"), Message(role: .assistant, content: "Hi!")]
+ #expect(ChatViewModel.draftFingerprint(for: before) != ChatViewModel.draftFingerprint(for: after))
}
- @Test("Not eligible when sync or auto-save is disabled")
- func autoSaveIneligibleWhenDisabled() {
- #expect(!autoSaveEligible(syncEnabled: false))
- #expect(!autoSaveEligible(syncAutoSave: false))
- }
-
- @Test("Not eligible when sync isn't configured or the repo isn't cloned")
- func autoSaveIneligibleWhenNotConfiguredOrCloned() {
- #expect(!autoSaveEligible(syncConfigured: false))
- #expect(!autoSaveEligible(isCloned: false))
- }
-
- @Test("Not eligible below the minimum message count")
- func autoSaveIneligibleBelowMinimumMessages() {
- #expect(!autoSaveEligible(chatMessageCount: 2, minMessages: 4))
- }
-
- @Test("Not eligible if this exact conversation was already auto-saved")
- func autoSaveIneligibleWhenAlreadySaved() {
- #expect(!autoSaveEligible(lastSavedConversationId: "hash-a", currentConversationHash: "hash-a"))
- }
-
- @Test("Eligible when the last saved hash differs from the current conversation")
- func autoSaveEligibleWhenConversationChanged() {
- #expect(autoSaveEligible(lastSavedConversationId: "hash-a", currentConversationHash: "hash-b"))
+ @Test("Empty message list has a stable fingerprint")
+ func draftFingerprintEmptyIsStable() {
+ #expect(ChatViewModel.draftFingerprint(for: []) == ChatViewModel.draftFingerprint(for: []))
}
}
diff --git a/oAITests/DraftRecoveryServiceTests.swift b/oAITests/DraftRecoveryServiceTests.swift
new file mode 100644
index 0000000..5f02cb0
--- /dev/null
+++ b/oAITests/DraftRecoveryServiceTests.swift
@@ -0,0 +1,58 @@
+//
+// DraftRecoveryServiceTests.swift
+// oAITests
+//
+// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
+// Copyright (C) 2026 Rune Olsen
+
+import Testing
+import Foundation
+@testable import oAI
+
+@Suite("DraftRecoveryService")
+struct DraftRecoveryServiceTests {
+
+ private func makeService() -> (DraftRecoveryService, URL) {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("oai-draft-test-\(UUID().uuidString).json")
+ return (DraftRecoveryService(fileURL: url), url)
+ }
+
+ @Test("load() returns nil when no draft file exists")
+ func loadReturnsNilForMissingFile() {
+ let (service, _) = makeService()
+ #expect(service.load() == nil)
+ }
+
+ @Test("save() then load() round-trips the draft")
+ func saveThenLoadRoundTrips() {
+ let (service, url) = makeService()
+ defer { try? FileManager.default.removeItem(at: url) }
+
+ let draft = DraftConversation(
+ messages: [Message(role: .user, content: "Hello"), Message(role: .assistant, content: "Hi there")],
+ conversationId: UUID(),
+ conversationName: "Test Conversation",
+ modelId: "anthropic/claude-sonnet-4-5",
+ savedAt: Date()
+ )
+ service.save(draft)
+
+ let loaded = service.load()
+ #expect(loaded?.messages.count == 2)
+ #expect(loaded?.messages.first?.content == "Hello")
+ #expect(loaded?.conversationId == draft.conversationId)
+ #expect(loaded?.conversationName == "Test Conversation")
+ #expect(loaded?.modelId == "anthropic/claude-sonnet-4-5")
+ }
+
+ @Test("clear() removes the draft file")
+ func clearRemovesFile() {
+ let (service, _) = makeService()
+ service.save(DraftConversation(messages: [Message(role: .user, content: "Hi")], conversationId: nil, conversationName: nil, modelId: nil, savedAt: Date()))
+ #expect(service.load() != nil)
+
+ service.clear()
+ #expect(service.load() == nil)
+ }
+}