Add unsaved-changes save prompt and crash-recovery draft

Replaces heuristic auto-save (goodbye-phrase detection, idle timeout,
min-message count, on-model-switch) with a standard macOS unsaved-changes
gate (Save/Don't Save/Cancel) on New Chat, Clear Chat, Load Conversation,
and Quit. The Save dialog gained a folder picker with inline "New Folder…"
creation.

Separately, the in-progress conversation is periodically mirrored to disk
(DraftRecoveryService, configurable interval in Settings, default 10s) and
offered back on next launch if oAI crashes or is force-quit, including the
model that was selected.

Two real bugs found via ObjectIdentifier/log-based diagnosis before this
worked correctly:
- oAIApp.init() wired AppDelegate.chatViewModel from its own @State read,
  which returned a throwaway ChatViewModel instance distinct from the one
  ContentView actually renders. Wiring moved to ContentView.onAppear.
- NSApplication.shared.delegate as? AppDelegate always failed silently:
  @NSApplicationDelegateAdaptor registers an internal SwiftUI.AppDelegate
  wrapper as the real NSApp.delegate (same name, different type in a
  different module), which forwards protocol methods but isn't castable
  to our type. AppDelegate now tracks itself via a static `shared`.

Also guards checkForCrashRecoveryDraft() against running under
XCTestConfigurationFilePath — oAITests is app-hosted, so xcodebuild test
launches this same app, and a leftover draft file on disk would otherwise
hang the entire test run on a blocking NSAlert with no one to click it.
This commit is contained in:
2026-07-31 08:11:12 +02:00
parent a306aaef9a
commit e3557a87df
12 changed files with 585 additions and 490 deletions
+316 -245
View File
@@ -26,6 +26,90 @@ import os
import SwiftUI
import UniformTypeIdentifiers
#if os(macOS)
/// Backing object for the Save Chat accessory view's name field + folder picker. A plain
/// NSObject is needed here (rather than living on `ChatViewModel`) because `NSPopUpButton`/
/// `NSMenuItem` actions require an `@objc` target, and the `@Observable` `ChatViewModel` isn't one.
private final class ConversationSaveAccessory: NSObject {
let container: NSView
let nameField: NSTextField
private let folderPopup: NSPopUpButton
private var folders: [Folder]
private var lastGoodSelection: UUID? // the folder to revert to if "New Folder" is cancelled
init(defaultName: String, folders: [Folder], selectedFolderId: UUID?) {
self.folders = folders
self.lastGoodSelection = selectedFolderId
container = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 58))
nameField = NSTextField(frame: NSRect(x: 0, y: 30, width: 260, height: 24))
folderPopup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
super.init()
nameField.placeholderString = "Conversation name…"
nameField.stringValue = defaultName
container.addSubview(nameField)
rebuildMenu(selecting: selectedFolderId)
folderPopup.target = self
folderPopup.action = #selector(popupChanged)
container.addSubview(folderPopup)
}
private func rebuildMenu(selecting folderId: UUID?) {
folderPopup.removeAllItems()
folderPopup.addItem(withTitle: "No Folder")
for folder in folders {
folderPopup.addItem(withTitle: folder.name)
}
folderPopup.menu?.addItem(.separator())
folderPopup.addItem(withTitle: "New Folder…")
if let folderId, let idx = folders.firstIndex(where: { $0.id == folderId }) {
folderPopup.selectItem(at: idx + 1)
} else {
folderPopup.selectItem(at: 0)
}
}
@objc private func popupChanged() {
let lastIndex = folderPopup.numberOfItems - 1
guard folderPopup.indexOfSelectedItem == lastIndex else {
lastGoodSelection = resolvedFolderId
return
}
// "New Folder" was picked prompt inline, then create + select, or revert.
let nameAlert = NSAlert()
nameAlert.messageText = "New Folder"
nameAlert.informativeText = "Enter a name for the new folder:"
nameAlert.addButton(withTitle: "Create")
nameAlert.addButton(withTitle: "Cancel")
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
nameAlert.accessoryView = field
nameAlert.window.initialFirstResponder = field
guard nameAlert.runModal() == .alertFirstButtonReturn else {
rebuildMenu(selecting: lastGoodSelection)
return
}
let newName = field.stringValue.trimmingCharacters(in: .whitespaces)
guard !newName.isEmpty, let created = try? DatabaseService.shared.createFolder(name: newName) else {
rebuildMenu(selecting: lastGoodSelection)
return
}
folders.append(created)
rebuildMenu(selecting: created.id)
lastGoodSelection = .some(created.id)
}
var resolvedFolderId: UUID? {
let idx = folderPopup.indexOfSelectedItem
guard idx >= 1, idx - 1 < folders.count else { return nil }
return folders[idx - 1].id
}
}
#endif
@Observable
@MainActor
class ChatViewModel {
@@ -68,11 +152,11 @@ class ChatViewModel {
return chatCount > 0 && chatCount != savedMessageCount
}
// MARK: - Auto-Save Tracking
// MARK: - Crash-Recovery Draft
private var conversationStartTime: Date?
private var lastMessageTime: Date?
private var idleCheckTimer: Timer?
private var draftTimer: Timer?
private var lastDraftFingerprint: Int?
private var hasCheckedForCrashRecoveryDraft = false
// MARK: - Private State
@@ -230,6 +314,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
Task {
await loadAvailableModels()
}
startDraftTimer()
}
// MARK: - Public Methods
@@ -245,8 +331,16 @@ Don't narrate future actions ("Let me...") - just use the tools.
Task { await loadAvailableModels() }
}
/// Start a new conversation
/// Start a new conversation gated behind the unsaved-changes prompt if needed.
func newConversation() {
#if os(macOS)
confirmDiscardIfNeeded(then: performNewConversation)
#else
performNewConversation()
#endif
}
private func performNewConversation() {
messages = []
sessionStats = SessionStats()
inputText = ""
@@ -264,6 +358,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
memoryEnabled = settings.memoryEnabled
mcpEnabled = settings.mcpEnabled
mcpStatus = mcpEnabled ? "MCP" : nil
startDraftTimer()
if providerChanged {
selectedModel = nil
@@ -341,6 +436,10 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages.append(userMessage)
sessionStats.addMessage(inputTokens: userMessage.tokens, outputTokens: nil, cost: nil)
// Persist the crash-recovery draft immediately rather than waiting for the next
// periodic tick otherwise a force-quit shortly after sending loses this message.
persistDraftIfChanged()
// Generate embedding for user message
generateEmbeddingForMessage(userMessage)
@@ -352,11 +451,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
// Clear input
inputText = ""
// Check auto-save triggers in background
Task {
await checkAutoSaveTriggersAfterMessage(cleanText)
}
// Generate real AI response
generateAIResponse(to: cleanText, attachments: userMessage.attachments)
}
@@ -375,14 +469,32 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
}
/// Clear the current chat gated behind the unsaved-changes prompt if needed.
func clearChat() {
#if os(macOS)
confirmDiscardIfNeeded(then: performClearChat)
#else
performClearChat()
#endif
}
private func performClearChat() {
messages.removeAll()
sessionStats.reset()
MCPService.shared.resetBashSessionApproval()
showSystemMessage("Chat cleared")
}
/// Load a saved conversation gated behind the unsaved-changes prompt if needed.
func loadConversation(_ conversation: Conversation) {
#if os(macOS)
confirmDiscardIfNeeded(then: { [weak self] in self?.performLoadConversation(conversation) })
#else
performLoadConversation(conversation)
#endif
}
private func performLoadConversation(_ conversation: Conversation) {
do {
guard let (_, loadedMessages) = try DatabaseService.shared.loadConversation(id: conversation.id) else {
showSystemMessage("Could not load conversation '\(conversation.name)'")
@@ -528,72 +640,57 @@ Don't narrate future actions ("Let me...") - just use the tools.
// MARK: - Quick Save
/// Called from the File menu re-saves if already named, shows NSAlert to name if not.
/// Called from the File menu re-saves if already named, prompts for name + folder if not.
func saveFromMenu() {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else { return }
if currentConversationName != nil {
quickSave()
} else {
#if os(macOS)
let alert = NSAlert()
alert.messageText = "Save Chat"
alert.informativeText = "Enter a name for this conversation:"
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
input.placeholderString = "Conversation name…"
alert.accessoryView = input
alert.window.initialFirstResponder = input
guard alert.runModal() == .alertFirstButtonReturn else { return }
let name = input.stringValue.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
do {
let saved = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages)
currentConversationId = saved.id
currentConversationName = name
savedMessageCount = chatMessages.count
showSystemMessage("Saved as \"\(name)\"")
} catch {
showSystemMessage("Save failed: \(error.localizedDescription)")
}
#endif
}
#if os(macOS)
attemptSaveCurrentConversation()
#endif
}
/// Always prompts for a new name and saves a fresh copy, switching the session to that copy.
/// Always prompts for a new name (and folder) and saves a fresh copy, switching the session to that copy.
func saveAsFromMenu() {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else { return }
#if os(macOS)
let alert = NSAlert()
alert.messageText = "Save Chat As"
alert.informativeText = "Enter a name for this conversation:"
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
input.placeholderString = "Conversation name…"
if let existing = currentConversationName {
input.stringValue = existing // pre-fill with current name as a starting point
}
alert.accessoryView = input
alert.window.initialFirstResponder = input
guard alert.runModal() == .alertFirstButtonReturn else { return }
let name = input.stringValue.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
guard let details = promptForConversationDetails(title: "Save Chat As", defaultName: currentConversationName ?? "") else { return }
do {
let saved = try DatabaseService.shared.saveConversation(name: name, messages: chatMessages)
let saved = try DatabaseService.shared.saveConversation(
id: UUID(), name: details.name, messages: chatMessages,
primaryModel: selectedModel?.id, folderId: details.folderId
)
currentConversationId = saved.id
currentConversationName = name
currentConversationName = details.name
savedMessageCount = chatMessages.count
showSystemMessage("Saved as \"\(name)\"")
showSystemMessage("Saved as \"\(details.name)\"")
Task { await GitSyncService.shared.autoSync() }
} catch {
showSystemMessage("Save failed: \(error.localizedDescription)")
}
#endif
}
#if os(macOS)
/// Shows the "Save Chat" alert with a name field and folder picker (including inline
/// "New Folder" creation). Returns nil if the user cancels or leaves the name empty.
private func promptForConversationDetails(title: String = "Save Chat", defaultName: String) -> (name: String, folderId: UUID?)? {
let folders = (try? DatabaseService.shared.listFolders()) ?? []
let accessory = ConversationSaveAccessory(defaultName: defaultName, folders: folders, selectedFolderId: nil)
let alert = NSAlert()
alert.messageText = title
alert.informativeText = "Enter a name for this conversation:"
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
alert.accessoryView = accessory.container
alert.window.initialFirstResponder = accessory.nameField
guard alert.runModal() == .alertFirstButtonReturn else { return nil }
let name = accessory.nameField.stringValue.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return nil }
return (name, accessory.resolvedFolderId)
}
#endif
/// Called by ConversationListView after renaming a saved conversation.
/// Updates the in-session name if the renamed conversation is currently open.
func didRenameConversation(id: UUID, newName: String) {
@@ -1318,7 +1415,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
}
}
_ = Self.detectGoodbyePhrase(in: "")
} catch {
if let index = messages.firstIndex(where: { $0.id == messageId }) {
messages[index].content = "❌ Image generation failed: \(error.localizedDescription)"
@@ -1963,214 +2059,189 @@ Don't narrate future actions ("Let me...") - just use the tools.
}
}
/// Pure auto-save eligibility check, pulled out of `shouldAutoSave()` so the criteria
/// (enabled, configured, cloned, min message count, not-already-saved) can be tested
/// without a live ChatViewModel/GitSyncService/SettingsService.
nonisolated static func shouldAutoSave(
syncEnabled: Bool,
syncAutoSave: Bool,
syncConfigured: Bool,
isCloned: Bool,
chatMessageCount: Int,
minMessages: Int,
lastSavedConversationId: String?,
currentConversationHash: String
) -> Bool {
guard syncEnabled && syncAutoSave else { return false }
guard syncConfigured else { return false }
guard isCloned else { return false }
guard chatMessageCount >= minMessages else { return false }
if let lastSavedId = lastSavedConversationId, lastSavedId == currentConversationHash {
return false // Already saved this exact conversation
}
return true
// MARK: - Crash-Recovery Draft
/// Pure fingerprint of a message list's content, used to skip rewriting the crash-recovery
/// draft to disk when nothing has actually changed. Not stable across process launches
/// (String hashing is randomized per-run) only ever compared within a single session.
nonisolated static func draftFingerprint(for messages: [Message]) -> Int {
messages.map { "\($0.role.rawValue)|\($0.content)" }.joined(separator: "\u{1}").hashValue
}
/// Check if conversation should be auto-saved based on criteria
func shouldAutoSave() -> Bool {
let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
let currentHash = chatMessages.map { $0.content }.joined()
return Self.shouldAutoSave(
syncEnabled: settings.syncEnabled,
syncAutoSave: settings.syncAutoSave,
syncConfigured: settings.syncConfigured,
isCloned: GitSyncService.shared.syncStatus.isCloned,
chatMessageCount: chatMessages.count,
minMessages: settings.syncAutoSaveMinMessages,
lastSavedConversationId: settings.syncLastAutoSaveConversationId,
currentConversationHash: currentHash
)
}
/// (Re)schedules the periodic draft-persistence timer using the current
/// `settings.draftRecoveryIntervalSeconds`. Call again after Settings changes it.
/// A value of 0 disables the periodic tick (and clears any existing draft).
func startDraftTimer() {
draftTimer?.invalidate()
draftTimer = nil
/// Auto-save the current conversation with background summarization
func autoSaveConversation() async {
guard shouldAutoSave() else {
let interval = settings.draftRecoveryIntervalSeconds
guard interval > 0 else {
DraftRecoveryService.shared.clear()
return
}
Log.ui.info("Auto-saving conversation...")
// Get summary in background (hidden from user)
let summary = await summarizeConversationInBackground()
// Use summary as name, or fallback to timestamp
let conversationName: String
if let summary = summary, !summary.isEmpty {
conversationName = summary
} else {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm"
conversationName = "Conversation - \(formatter.string(from: Date()))"
}
// Save the conversation
do {
let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
let conversation = try DatabaseService.shared.saveConversation(
name: conversationName,
messages: chatMessages
)
currentConversationId = conversation.id
currentConversationName = conversationName
savedMessageCount = chatMessages.count
Log.ui.info("Auto-saved conversation: \(conversationName)")
// Check if progressive summarization is needed
Task {
await checkAndSummarizeOldMessages(conversationId: conversation.id)
}
// Generate embeddings for messages that don't have them yet.
// Run sequentially at background priority so this never blocks the chat.
if settings.embeddingsEnabled {
Task(priority: .background) {
guard let provider = EmbeddingService.shared.getSelectedProvider() else { return }
for message in chatMessages {
await embedMessage(message, provider: provider)
// Yield briefly between requests to avoid bursting the API
try? await Task.sleep(for: .milliseconds(150))
}
}
}
// Mark as saved to prevent duplicate saves
let conversationHash = chatMessages.map { $0.content }.joined()
settings.syncLastAutoSaveConversationId = conversationHash
// Trigger auto-sync (export + push)
Task {
await performAutoSync()
}
} catch {
Log.ui.error("Auto-save failed: \(error.localizedDescription)")
}
}
/// Perform auto-sync: export + push to git (debounced)
private func performAutoSync() async {
await GitSyncService.shared.autoSync()
}
// MARK: - Smart Triggers
/// Update conversation tracking times
func updateConversationTracking() {
let now = Date()
if conversationStartTime == nil {
conversationStartTime = now
}
lastMessageTime = now
// Restart idle timer if enabled
if settings.syncAutoSaveOnIdle {
startIdleTimer()
}
}
/// Start or restart the idle timer
private func startIdleTimer() {
// Cancel existing timer
idleCheckTimer?.invalidate()
let idleMinutes = settings.syncAutoSaveIdleMinutes
let idleSeconds = TimeInterval(idleMinutes * 60)
// Schedule new timer
idleCheckTimer = Timer.scheduledTimer(withTimeInterval: idleSeconds, repeats: false) { [weak self] _ in
draftTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(interval), repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
await self?.onIdleTimeout()
self?.persistDraftIfChanged()
}
}
}
/// Called when idle timeout is reached
private func onIdleTimeout() async {
guard settings.syncAutoSaveOnIdle else { return }
private func persistDraftIfChanged() {
guard settings.draftRecoveryIntervalSeconds > 0 else { return }
Log.ui.info("Idle timeout reached - triggering auto-save")
await autoSaveConversation()
let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant }
guard !chatMessages.isEmpty else { return }
let fingerprint = Self.draftFingerprint(for: chatMessages)
guard fingerprint != lastDraftFingerprint else { return }
lastDraftFingerprint = fingerprint
DraftRecoveryService.shared.save(DraftConversation(
messages: chatMessages,
conversationId: currentConversationId,
conversationName: currentConversationName,
modelId: selectedModel?.id,
savedAt: Date()
))
}
/// Detect goodbye phrases in user message
nonisolated static func detectGoodbyePhrase(in text: String) -> Bool {
let lowercased = text.lowercased()
let goodbyePhrases = [
"bye", "goodbye", "bye bye", "good bye",
"that's all", "thats all", "that'll be all",
"i'm done", "we're done",
"see you", "see ya", "catch you later",
"have a good day", "have a nice day"
]
/// Called once on launch from `ContentView.onAppear` in `ContentView.swift`, deferred one
/// run-loop tick via `DispatchQueue.main.async` so the modal alert reliably presents calling
/// it directly and undeferred from `.onAppear` was tried and failed silently. Previously called
/// from `AppDelegate.applicationDidFinishLaunching`, but that read a throwaway `ChatViewModel`
/// instance from `oAIApp.init()`'s own `@State` rather than the one actually rendered (confirmed
/// via ObjectIdentifier logging restore appeared to work but never touched the visible chat).
/// Offers to restore a conversation left behind by a crash or force-quit. A no-op after a clean
/// shutdown, since `confirmDiscardIfNeeded` always clears the draft before New Chat/Clear/Switch/Quit proceed.
func checkForCrashRecoveryDraft() {
guard !hasCheckedForCrashRecoveryDraft else { return }
hasCheckedForCrashRecoveryDraft = true
return goodbyePhrases.contains { phrase in
// Check for whole word match (not substring)
let pattern = "\\b\(NSRegularExpression.escapedPattern(for: phrase))\\b"
return lowercased.range(of: pattern, options: .regularExpression) != nil
}
}
// oAITests is app-hosted, so `xcodebuild test` launches this same app as the test host
// without this guard, a leftover draft file on disk (e.g. from manual kill-9 testing)
// makes the test host hit this exact blocking NSAlert.runModal() with no one there to
// click it, hanging the entire test run indefinitely.
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return }
/// Trigger auto-save when user switches models
func onModelSwitch(from oldModel: ModelInfo?, to newModel: ModelInfo?) async {
guard settings.syncAutoSaveOnModelSwitch else { return }
guard oldModel != nil else { return } // Don't save on first model selection
let loaded = DraftRecoveryService.shared.load()
Log.ui.info("checkForCrashRecoveryDraft: found draft = \(loaded != nil), messageCount = \(loaded?.messages.count ?? -1)")
guard let draft = loaded, !draft.messages.isEmpty else { return }
Log.ui.info("Model switch detected - triggering auto-save")
await autoSaveConversation()
}
#if os(macOS)
// Bring the app frontmost first this runs one tick after launch, before the app has
// necessarily activated, and an app-modal alert shown to a non-active app can end up
// behind other windows (clicks land on whatever's actually frontmost, not the alert).
NSApp.activate(ignoringOtherApps: true)
/// Trigger auto-save on app quit
func onAppWillTerminate() async {
guard settings.syncAutoSaveOnAppQuit else { return }
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = "Restore unsaved conversation?"
alert.informativeText = "oAI didn't close properly last time. Would you like to restore the conversation you were working on?"
alert.addButton(withTitle: "Restore")
alert.addButton(withTitle: "Discard")
Log.ui.info("App quit detected - triggering auto-save")
await autoSaveConversation()
}
/// Check and trigger auto-save after user message
func checkAutoSaveTriggersAfterMessage(_ text: String) async {
// Update tracking
updateConversationTracking()
// Check for goodbye phrase
if Self.detectGoodbyePhrase(in: text) {
Log.ui.info("Goodbye phrase detected - triggering auto-save")
// Wait a bit to see if user continues
try? await Task.sleep(for: .seconds(30))
// Check if they sent another message in the meantime
if let lastTime = lastMessageTime, Date().timeIntervalSince(lastTime) < 25 {
Log.ui.info("User continued chatting - skipping goodbye auto-save")
return
if alert.runModal() == .alertFirstButtonReturn {
messages = draft.messages
currentConversationId = draft.conversationId
currentConversationName = draft.conversationName
savedMessageCount = 0 // always treat a restored draft as unsaved
// Clear the on-disk draft now that it's loaded into memory otherwise this exact
// file lingers forever (a clean quit only clears it via confirmDiscardIfNeeded, which
// never runs if the restored session is then quit before any new message is sent),
// and the same restore prompt reappears on every future launch no matter what the
// user picks here.
DraftRecoveryService.shared.clear()
showSystemMessage("Restored previous session (^[\(draft.messages.count) message](inflect: true))")
if let modelId = draft.modelId {
Task { await switchToConversationModel(modelId) }
}
} else {
DraftRecoveryService.shared.clear()
}
#endif
}
await autoSaveConversation()
// MARK: - Unsaved Changes Gate
#if os(macOS)
/// Standard macOS "unsaved changes" gate. If there are no unsaved changes, `proceed()` runs
/// immediately. Otherwise shows a Save / Don't Save / Cancel alert: Save attempts an explicit
/// save (prompting for name/folder if never named) and only proceeds on success; Don't Save
/// discards the crash-recovery draft and proceeds; Cancel calls `onCancel()` the caller's
/// original action (new chat / clear / switch / quit) must not happen.
func confirmDiscardIfNeeded(then proceed: @escaping () -> Void, onCancel: @escaping () -> Void = {}) {
guard hasUnsavedChanges else { proceed(); return }
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Do you want to save the changes you made to \"\(currentConversationName ?? "Untitled Conversation")\"?"
alert.informativeText = "Your changes will be lost if you don't save them."
alert.addButton(withTitle: "Save")
let dontSave = alert.addButton(withTitle: "Don't Save")
dontSave.keyEquivalent = "d"
dontSave.keyEquivalentModifierMask = .command
alert.addButton(withTitle: "Cancel")
switch alert.runModal() {
case .alertFirstButtonReturn:
if attemptSaveCurrentConversation() {
DraftRecoveryService.shared.clear()
proceed()
} else {
onCancel()
}
case .alertSecondButtonReturn:
DraftRecoveryService.shared.clear()
proceed()
default:
onCancel()
}
}
/// Saves the current conversation: silently re-saves in place if already named, otherwise
/// prompts for a name (and optional folder) via `promptForConversationDetails`. Returns true
/// on success (or if there was nothing to save), false if cancelled or the save failed.
@discardableResult
private func attemptSaveCurrentConversation() -> Bool {
let chatMessages = messages.filter { $0.role != .system }
guard !chatMessages.isEmpty else { return true }
if let id = currentConversationId, let name = currentConversationName {
do {
try DatabaseService.shared.updateConversation(
id: id, name: name, messages: chatMessages, primaryModel: selectedModel?.id
)
savedMessageCount = chatMessages.count
showSystemMessage("Saved \"\(name)\"")
Task { await GitSyncService.shared.autoSync() }
return true
} catch {
showSystemMessage("Save failed: \(error.localizedDescription)")
return false
}
}
guard let details = promptForConversationDetails(defaultName: "") else { return false }
do {
let saved = try DatabaseService.shared.saveConversation(
id: UUID(), name: details.name, messages: chatMessages,
primaryModel: selectedModel?.id, folderId: details.folderId
)
currentConversationId = saved.id
currentConversationName = details.name
savedMessageCount = chatMessages.count
showSystemMessage("Saved as \"\(details.name)\"")
Task { await GitSyncService.shared.autoSync() }
return true
} catch {
showSystemMessage("Save failed: \(error.localizedDescription)")
return false
}
}
#endif
// MARK: - Embedding Generation
/// Generate embedding for a single message (awaitable, no Task spawned).