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