Add unsaved-changes save prompt and crash-recovery draft
Replaces heuristic auto-save (goodbye-phrase detection, idle timeout, min-message count, on-model-switch) with a standard macOS unsaved-changes gate (Save/Don't Save/Cancel) on New Chat, Clear Chat, Load Conversation, and Quit. The Save dialog gained a folder picker with inline "New Folder…" creation. Separately, the in-progress conversation is periodically mirrored to disk (DraftRecoveryService, configurable interval in Settings, default 10s) and offered back on next launch if oAI crashes or is force-quit, including the model that was selected. Two real bugs found via ObjectIdentifier/log-based diagnosis before this worked correctly: - oAIApp.init() wired AppDelegate.chatViewModel from its own @State read, which returned a throwaway ChatViewModel instance distinct from the one ContentView actually renders. Wiring moved to ContentView.onAppear. - NSApplication.shared.delegate as? AppDelegate always failed silently: @NSApplicationDelegateAdaptor registers an internal SwiftUI.AppDelegate wrapper as the real NSApp.delegate (same name, different type in a different module), which forwards protocol methods but isn't castable to our type. AppDelegate now tracks itself via a static `shared`. Also guards checkForCrashRecoveryDraft() against running under XCTestConfigurationFilePath — oAITests is app-hosted, so xcodebuild test launches this same app, and a leftover draft file on disk would otherwise hang the entire test run on a blocking NSAlert with no one to click it.
This commit is contained in:
@@ -511,9 +511,22 @@
|
||||
|
||||
<p>From the <strong>File menu</strong> you also have:</p>
|
||||
<ul>
|
||||
<li><strong>Save Chat (<kbd>⌘S</kbd>)</strong> — Re-saves under the current name, or prompts for a name if the conversation hasn't been saved yet.</li>
|
||||
<li><strong>Save Chat As…</strong> — Always prompts for a new name and creates a fresh copy, switching the session to that copy. Useful for branching a conversation.</li>
|
||||
<li><strong>Save Chat (<kbd>⌘S</kbd>)</strong> — Re-saves under the current name, or prompts for a name and folder if the conversation hasn't been saved yet.</li>
|
||||
<li><strong>Save Chat As…</strong> — Always prompts for a new name and folder and creates a fresh copy, switching the session to that copy. Useful for branching a conversation.</li>
|
||||
</ul>
|
||||
<p class="note">The Save dialog includes a folder picker — choose an existing folder or pick <strong>New Folder…</strong> to create one on the spot.</p>
|
||||
|
||||
<h3 id="unsaved-changes">Unsaved Changes & Crash Recovery</h3>
|
||||
<p>oAI tracks unsaved changes like a standard Mac document. Starting a New Chat, clearing the chat, loading a different conversation, or quitting the app while there are unsaved messages shows the standard save prompt:</p>
|
||||
<ul>
|
||||
<li><strong>Save</strong> — saves the conversation (prompting for a name and folder if it hasn't been saved yet), then proceeds</li>
|
||||
<li><strong>Don't Save</strong> (<kbd>⌘D</kbd> in the prompt) — discards the changes and proceeds</li>
|
||||
<li><strong>Cancel</strong> — stops the action so nothing is lost</li>
|
||||
</ul>
|
||||
|
||||
<div class="tip">
|
||||
<strong>💡 Crash Recovery:</strong> While you're chatting, oAI periodically mirrors the in-progress conversation to disk (every 10 seconds by default — adjustable or turned off in Settings → Advanced). If oAI crashes or is force-quit, the next launch offers to restore the conversation you were working on, including the model you had selected. This mirror is invisible and separate from your saved conversations — it's cleared automatically as soon as you save, discard, or restore it.
|
||||
</div>
|
||||
|
||||
<h3>Renaming Conversations</h3>
|
||||
<p>In the Conversations list (<kbd>⌘L</kbd>):</p>
|
||||
@@ -605,27 +618,10 @@
|
||||
<li>Click <strong>Clone Repository</strong> to initialize</li>
|
||||
</ol>
|
||||
|
||||
<h3>Auto-Save Features</h3>
|
||||
<p>When auto-save is enabled, oAI automatically saves and syncs conversations based on triggers:</p>
|
||||
|
||||
<h4>Auto-Save Triggers</h4>
|
||||
<ul>
|
||||
<li><strong>On App Start</strong> - Pulls and imports changes when oAI launches (no push)</li>
|
||||
<li><strong>On Model Switch</strong> - Saves when you change AI models</li>
|
||||
<li><strong>On App Quit</strong> - Saves before oAI closes</li>
|
||||
<li><strong>After Idle Timeout</strong> - Saves after 5 seconds of inactivity</li>
|
||||
</ul>
|
||||
|
||||
<h4>Auto-Save Settings</h4>
|
||||
<ul>
|
||||
<li><strong>Minimum Messages</strong> - Only save conversations with at least N messages (default: 5)</li>
|
||||
<li><strong>Auto-Export</strong> - Export conversations to markdown after save</li>
|
||||
<li><strong>Auto-Commit</strong> - Commit changes to git automatically</li>
|
||||
<li><strong>Auto-Push</strong> - Push commits to remote repository</li>
|
||||
</ul>
|
||||
<p>Saving to Git is explicit rather than automatic — see <a href="#unsaved-changes">Unsaved Changes & Crash Recovery</a> for how oAI tracks and prompts you to save. Every explicit save (<kbd>⌘S</kbd>, Save Chat As…, or the unsaved-changes prompt) triggers a background sync (export + commit + push) automatically when Git Sync is configured.</p>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ Multi-Machine Warning:</strong> Running auto-sync on multiple machines simultaneously can cause merge conflicts. Use auto-sync on your primary machine only, or manually sync on others.
|
||||
<strong>⚠️ Multi-Machine Warning:</strong> Syncing on multiple machines simultaneously can cause merge conflicts. Pull before you start working on a different machine.
|
||||
</div>
|
||||
|
||||
<h3>Manual Sync Operations</h3>
|
||||
@@ -1544,11 +1540,17 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
<dt><kbd>⌘M</kbd></dt>
|
||||
<dd>Model Selector</dd>
|
||||
|
||||
<dt><kbd>⌘N</kbd></dt>
|
||||
<dd>New Chat (prompts to save first if there are unsaved changes)</dd>
|
||||
|
||||
<dt><kbd>⌘K</kbd></dt>
|
||||
<dd>Clear Chat</dd>
|
||||
<dd>Clear Chat (prompts to save first if there are unsaved changes)</dd>
|
||||
|
||||
<dt><kbd>⌘O</kbd></dt>
|
||||
<dd>Open Chat…</dd>
|
||||
|
||||
<dt><kbd>⌘S</kbd></dt>
|
||||
<dd>Save Chat (re-saves if already named, prompts for name otherwise)</dd>
|
||||
<dd>Save Chat (re-saves if already named, prompts for name and folder otherwise)</dd>
|
||||
|
||||
<dt><kbd>⇧⌘S</kbd></dt>
|
||||
<dd>Show Statistics</dd>
|
||||
@@ -1609,13 +1611,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
<li><strong>Authentication Method</strong> - Choose SSH, Password, or Access Token</li>
|
||||
<li><strong>Credentials</strong> - Enter username/password or access token (encrypted storage)</li>
|
||||
<li><strong>Local Path</strong> - Where to clone the repository locally (default: ~/oAI-Sync)</li>
|
||||
<li><strong>Auto-Save Settings</strong>:
|
||||
<ul>
|
||||
<li><strong>Enable Auto-Save</strong> - Automatically sync conversations</li>
|
||||
<li><strong>Minimum Messages</strong> - Only sync conversations with at least N messages</li>
|
||||
<li><strong>Triggers</strong> - Sync on app start, idle, goodbye phrases, model switch, or app quit</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Syncing happens automatically after every explicit save — no separate auto-save toggle needed. See <a href="#unsaved-changes">Unsaved Changes & Crash Recovery</a>.</li>
|
||||
<li><strong>Manual Sync</strong>:
|
||||
<ul>
|
||||
<li><strong>Initialize Repository</strong> - Clone repository for first-time setup</li>
|
||||
@@ -1702,6 +1698,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
<li><strong>Smart Context Selection</strong> — Intelligently select relevant messages to reduce token usage</li>
|
||||
<li><strong>Semantic Search</strong> — Enable AI-powered conversation search using embeddings</li>
|
||||
<li><strong>Progressive Summarization</strong> — Automatically summarize old portions of long conversations</li>
|
||||
<li><strong>Crash Recovery</strong> — how often the in-progress conversation is mirrored to disk (Off, 1s, 10s, 30s, 60s); see <a href="#unsaved-changes">Unsaved Changes & Crash Recovery</a></li>
|
||||
</ul>
|
||||
|
||||
<h3>Backup Tab</h3>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// DraftRecoveryService.swift
|
||||
// oAI
|
||||
//
|
||||
// Crash-recovery draft for the in-progress conversation — a lightweight,
|
||||
// invisible mirror of the current chat, distinct from named saved conversations.
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
//
|
||||
// This file is part of oAI.
|
||||
//
|
||||
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||
// You may use, study, modify, and share it for any noncommercial
|
||||
// purpose. Commercial use — including selling oAI or any part of
|
||||
// it, standalone or bundled into another product or service —
|
||||
// requires a separate commercial license from the copyright holder.
|
||||
//
|
||||
// See the LICENSE file or
|
||||
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||
// the full license text. For commercial licensing, contact Rune
|
||||
// Olsen via <https://oai.pm>.
|
||||
|
||||
|
||||
import Foundation
|
||||
|
||||
struct DraftConversation: Codable, Sendable {
|
||||
let messages: [Message]
|
||||
let conversationId: UUID?
|
||||
let conversationName: String?
|
||||
let modelId: String?
|
||||
let savedAt: Date
|
||||
}
|
||||
|
||||
final class DraftRecoveryService: Sendable {
|
||||
static let shared = DraftRecoveryService()
|
||||
|
||||
private let fileURL: URL
|
||||
|
||||
/// - Parameter fileURL: injection point for tests; production uses the default
|
||||
/// Application Support location, matching `DatabaseService`'s pattern.
|
||||
nonisolated init(fileURL: URL? = nil) {
|
||||
if let fileURL {
|
||||
self.fileURL = fileURL
|
||||
} else {
|
||||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory,
|
||||
in: .userDomainMask).first!
|
||||
let dir = appSupport.appendingPathComponent("oAI", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
self.fileURL = dir.appendingPathComponent("draft_conversation.json")
|
||||
}
|
||||
}
|
||||
|
||||
func save(_ draft: DraftConversation) {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(draft)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
Log.db.info("DraftRecoveryService: wrote draft to \(fileURL.path)")
|
||||
} catch {
|
||||
Log.db.error("DraftRecoveryService: save failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func load() -> DraftConversation? {
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
return try JSONDecoder().decode(DraftConversation.self, from: data)
|
||||
} catch {
|
||||
Log.db.info("DraftRecoveryService: load found nothing (\(error.localizedDescription))")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func clear() {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
@@ -1005,66 +1005,15 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auto-Sync Settings
|
||||
// MARK: - Crash-Recovery Draft Settings
|
||||
|
||||
var syncAutoSave: Bool {
|
||||
get { cache["syncAutoSave"] == "true" }
|
||||
/// How often (in seconds) the in-progress conversation is mirrored to a local
|
||||
/// crash-recovery draft, so a force-quit/crash doesn't lose it. 0 = off.
|
||||
var draftRecoveryIntervalSeconds: Int {
|
||||
get { cache["draftRecoveryIntervalSeconds"].flatMap(Int.init) ?? 10 }
|
||||
set {
|
||||
cache["syncAutoSave"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoSave", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncAutoSaveMinMessages: Int {
|
||||
get { cache["syncAutoSaveMinMessages"].flatMap(Int.init) ?? 5 }
|
||||
set {
|
||||
cache["syncAutoSaveMinMessages"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoSaveMinMessages", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncAutoSaveOnModelSwitch: Bool {
|
||||
get { cache["syncAutoSaveOnModelSwitch"] == "true" }
|
||||
set {
|
||||
cache["syncAutoSaveOnModelSwitch"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoSaveOnModelSwitch", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncAutoSaveOnAppQuit: Bool {
|
||||
get { cache["syncAutoSaveOnAppQuit"] == "true" }
|
||||
set {
|
||||
cache["syncAutoSaveOnAppQuit"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoSaveOnAppQuit", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncAutoSaveOnIdle: Bool {
|
||||
get { cache["syncAutoSaveOnIdle"] == "true" }
|
||||
set {
|
||||
cache["syncAutoSaveOnIdle"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoSaveOnIdle", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncAutoSaveIdleMinutes: Int {
|
||||
get { cache["syncAutoSaveIdleMinutes"].flatMap(Int.init) ?? 5 }
|
||||
set {
|
||||
cache["syncAutoSaveIdleMinutes"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoSaveIdleMinutes", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncLastAutoSaveConversationId: String? {
|
||||
get { cache["syncLastAutoSaveConversationId"] }
|
||||
set {
|
||||
if let value = newValue {
|
||||
cache["syncLastAutoSaveConversationId"] = value
|
||||
DatabaseService.shared.setSetting(key: "syncLastAutoSaveConversationId", value: value)
|
||||
} else {
|
||||
cache.removeValue(forKey: "syncLastAutoSaveConversationId")
|
||||
DatabaseService.shared.deleteSetting(key: "syncLastAutoSaveConversationId")
|
||||
}
|
||||
cache["draftRecoveryIntervalSeconds"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "draftRecoveryIntervalSeconds", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+316
-245
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,9 +140,6 @@ struct SyncStatusIndicator: View {
|
||||
.onChange(of: settings.syncEnabled) {
|
||||
updateState()
|
||||
}
|
||||
.onChange(of: settings.syncAutoSave) {
|
||||
updateState()
|
||||
}
|
||||
}
|
||||
|
||||
private var statusIcon: some View {
|
||||
|
||||
@@ -403,6 +403,31 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
}
|
||||
|
||||
// Crash Recovery
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Crash Recovery")
|
||||
formSection {
|
||||
row("Save Draft Every") {
|
||||
Picker("", selection: $settingsService.draftRecoveryIntervalSeconds) {
|
||||
Text("Off").tag(0)
|
||||
Text("1 second").tag(1)
|
||||
Text("10 seconds").tag(10)
|
||||
Text("30 seconds").tag(30)
|
||||
Text("60 seconds").tag(60)
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Mirrors your in-progress conversation to disk so a crash or force-quit doesn't lose it. Never shown as a saved conversation.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Web Search
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Web Search")
|
||||
@@ -1697,72 +1722,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-Save
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Auto-Save")
|
||||
formSection {
|
||||
row("Enable Auto-Save") {
|
||||
Toggle("", isOn: $settingsService.syncAutoSave)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
if settingsService.syncAutoSave {
|
||||
rowDivider()
|
||||
row("Min Messages") {
|
||||
HStack {
|
||||
Slider(value: Binding(
|
||||
get: { Double(settingsService.syncAutoSaveMinMessages) },
|
||||
set: { settingsService.syncAutoSaveMinMessages = Int($0) }
|
||||
), in: 3...20, step: 1)
|
||||
.frame(width: 200)
|
||||
Text("\(settingsService.syncAutoSaveMinMessages)")
|
||||
.font(.system(size: 14))
|
||||
.frame(width: 30)
|
||||
}
|
||||
}
|
||||
rowDivider()
|
||||
row("On model switch") {
|
||||
Toggle("", isOn: $settingsService.syncAutoSaveOnModelSwitch)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
rowDivider()
|
||||
row("On app quit") {
|
||||
Toggle("", isOn: $settingsService.syncAutoSaveOnAppQuit)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
rowDivider()
|
||||
row("After idle timeout") {
|
||||
Toggle("", isOn: $settingsService.syncAutoSaveOnIdle)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
if settingsService.syncAutoSaveOnIdle {
|
||||
rowDivider()
|
||||
row("Idle Timeout") {
|
||||
HStack {
|
||||
Slider(value: Binding(
|
||||
get: { Double(settingsService.syncAutoSaveIdleMinutes) },
|
||||
set: { settingsService.syncAutoSaveIdleMinutes = Int($0) }
|
||||
), in: 1...30, step: 1)
|
||||
.frame(width: 200)
|
||||
Text("\(settingsService.syncAutoSaveIdleMinutes) min")
|
||||
.font(.system(size: 14))
|
||||
.frame(width: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if settingsService.syncAutoSave {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text("Auto-sync can cause conflicts if running on multiple machines simultaneously.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
// Manual Sync
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Manual Sync")
|
||||
|
||||
+38
-3
@@ -24,12 +24,50 @@
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
/// Intercepts Quit so an unsaved conversation gets the standard "Do you want to save?" prompt
|
||||
/// (via `ChatViewModel.confirmDiscardIfNeeded`) with a real chance to cancel the quit.
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// `NSApplication.shared.delegate as? AppDelegate` (formerly used in ContentView.onAppear to
|
||||
// wire `chatViewModel`) ALWAYS fails: `@NSApplicationDelegateAdaptor` registers an internal
|
||||
// `SwiftUI.AppDelegate` wrapper as the real `NSApp.delegate`, which forwards protocol methods
|
||||
// (like `applicationShouldTerminate`) to this instance — but querying `NSApp.delegate` returns
|
||||
// that SwiftUI wrapper, a same-named-but-different type, not this class. Confirmed via logging
|
||||
// ("delegate is Optional(<SwiftUI.AppDelegate: ...>)"). Track our own instance directly instead.
|
||||
static var shared: AppDelegate?
|
||||
|
||||
var chatViewModel: ChatViewModel?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
AppDelegate.shared = self
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard let chatViewModel, chatViewModel.hasUnsavedChanges else { return .terminateNow }
|
||||
var shouldTerminate = false
|
||||
chatViewModel.confirmDiscardIfNeeded(
|
||||
then: { shouldTerminate = true },
|
||||
onCancel: { shouldTerminate = false }
|
||||
)
|
||||
return shouldTerminate ? .terminateNow : .terminateCancel
|
||||
}
|
||||
|
||||
// `chatViewModel` is wired from `ContentView.onAppear`, not from `oAIApp.init()` — reading
|
||||
// the `@State private var chatViewModel` there returns a throwaway instance distinct from
|
||||
// the one SwiftUI actually renders (confirmed via ObjectIdentifier logging: two different
|
||||
// addresses), which silently broke crash-recovery restore. `ContentView.onAppear` reads the
|
||||
// same environment-injected instance the view tree observes, so it's guaranteed correct.
|
||||
}
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct oAIApp: App {
|
||||
@State private var chatViewModel = ChatViewModel()
|
||||
@State private var showAbout = false
|
||||
#if os(macOS)
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
#endif
|
||||
|
||||
init() {
|
||||
// Start email handler on app launch
|
||||
@@ -68,9 +106,6 @@ struct oAIApp: App {
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
||||
Task {
|
||||
await chatViewModel.onAppWillTerminate()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user