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:

+

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:

+ + +
+ 💡 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

    - - -

    Auto-Save Settings

    - +

    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: - -
  • +
  • Syncing happens automatically after every explicit save — no separate auto-save toggle needed. See Unsaved Changes & Crash Recovery.
  • Manual Sync:

    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) + } +}