diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index 177d8e0..898331f 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -279,7 +279,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.3.5; + MARKETING_VERSION = 2.3.6; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -323,7 +323,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.3.5; + MARKETING_VERSION = 2.3.6; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index f14e4cd..35474ce 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -551,6 +551,46 @@ Don't narrate future actions ("Let me...") - just use the tools. } } + /// Always prompts for a new name 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 } + 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 + } + + /// 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) { + if currentConversationId == id { + currentConversationName = newName + } + } + /// Re-save the current conversation under its existing name, or prompt if never saved. func quickSave() { let chatMessages = messages.filter { $0.role != .system } diff --git a/oAI/Views/Main/ContentView.swift b/oAI/Views/Main/ContentView.swift index 145e256..c664615 100644 --- a/oAI/Views/Main/ContentView.swift +++ b/oAI/Views/Main/ContentView.swift @@ -96,9 +96,14 @@ struct ContentView: View { CreditsView(provider: chatViewModel.currentProvider) } .sheet(isPresented: $vm.showConversations) { - ConversationListView(onLoad: { conversation in - chatViewModel.loadConversation(conversation) - }) + ConversationListView( + onLoad: { conversation in + chatViewModel.loadConversation(conversation) + }, + onRename: { id, newName in + chatViewModel.didRenameConversation(id: id, newName: newName) + } + ) } .sheet(item: $vm.modelInfoTarget) { model in ModelInfoView(model: model) diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index 682995d..5ba8fb2 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -39,6 +39,7 @@ struct ConversationListView: View { @FocusState private var searchFocused: Bool private let settings = SettingsService.shared var onLoad: ((Conversation) -> Void)? + var onRename: ((UUID, String) -> Void)? private var filteredConversations: [Conversation] { if searchText.isEmpty { @@ -216,6 +217,16 @@ struct ConversationListView: View { Spacer() if !isSelecting { + Button { + renameConversation(conversation) + } label: { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .font(.system(size: 15)) + } + .buttonStyle(.plain) + .help("Rename conversation") + Button { deleteConversation(conversation) } label: { @@ -240,6 +251,13 @@ struct ConversationListView: View { Label("Delete", systemImage: "trash") } + Button { + renameConversation(conversation) + } label: { + Label("Rename", systemImage: "pencil") + } + .tint(.orange) + Button { exportConversation(conversation) } label: { @@ -315,6 +333,35 @@ struct ConversationListView: View { selectedIndex = 0 } + private func renameConversation(_ conversation: Conversation) { + #if os(macOS) + let alert = NSAlert() + alert.messageText = "Rename Conversation" + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") + let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + input.stringValue = conversation.name + input.selectText(nil) + alert.accessoryView = input + alert.window.initialFirstResponder = input + guard alert.runModal() == .alertFirstButtonReturn else { return } + let newName = input.stringValue.trimmingCharacters(in: .whitespaces) + guard !newName.isEmpty, newName != conversation.name else { return } + do { + _ = try DatabaseService.shared.updateConversation(id: conversation.id, name: newName, messages: nil) + withAnimation { + if let i = conversations.firstIndex(where: { $0.id == conversation.id }) { + conversations[i].name = newName + conversations[i].updatedAt = Date() + } + } + onRename?(conversation.id, newName) + } catch { + Log.db.error("Failed to rename conversation: \(error.localizedDescription)") + } + #endif + } + private func deleteConversation(_ conversation: Conversation) { do { let _ = try DatabaseService.shared.deleteConversation(id: conversation.id) diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 162d97d..f196a81 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -42,8 +42,8 @@ struct SettingsView: View { @State private var openaiKey = "" @State private var googleKey = "" @State private var googleEngineID = "" - @State private var showFolderPicker = false @State private var selectedTab = 0 + @State private var isFolderDropTargeted = false @State private var logLevel: LogLevel = FileLogger.shared.minimumLevel // Git Sync state @@ -412,18 +412,21 @@ It's better to admit "I need more information" or "I cannot do that" than to fak // Folders VStack(alignment: .leading, spacing: 6) { sectionHeader("Allowed Folders") - formSection { + // Folder list — also a drop target for Finder drags + VStack(spacing: 0) { if mcpService.allowedFolders.isEmpty { VStack(spacing: 8) { Image(systemName: "folder.badge.plus") .font(.system(size: 32)) - .foregroundStyle(.tertiary) - Text("No folders added yet") + .foregroundStyle(isFolderDropTargeted ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.tertiary)) + Text(isFolderDropTargeted ? "Drop to add folder" : "No folders added yet") .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) - Text("Click 'Add Folder' below to grant AI access to a folder") - .font(.system(size: 13)) - .foregroundStyle(.tertiary) + .foregroundStyle(isFolderDropTargeted ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.secondary)) + if !isFolderDropTargeted { + Text("Click 'Add Folder' below or drag folders here from Finder") + .font(.system(size: 13)) + .foregroundStyle(.tertiary) + } } .frame(maxWidth: .infinity) .padding(.vertical, 24) @@ -458,32 +461,51 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } } } - } - HStack { + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke( + isFolderDropTargeted ? Color.accentColor : Color.primary.opacity(0.10), + lineWidth: isFolderDropTargeted ? 2 : 0.5 + ) + ) + .onDrop(of: [.fileURL], isTargeted: $isFolderDropTargeted) { providers in + for provider in providers { + _ = provider.loadObject(ofClass: URL.self) { url, _ in + guard let url, url.hasDirectoryPath else { return } + DispatchQueue.main.async { + withAnimation { _ = mcpService.addFolder(url.path) } + } + } + } + return true + } + + // Add Folder button Button { - showFolderPicker = true + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = true + panel.prompt = "Add" + panel.message = "Choose folders to allow AI access" + panel.begin { response in + guard response == .OK else { return } + for url in panel.urls { + withAnimation { _ = mcpService.addFolder(url.path) } + } + } } label: { - HStack(spacing: 4) { - Image(systemName: "plus") - Text("Add Folder...") - } - .font(.system(size: 14)) - } - .buttonStyle(.borderless) - Spacer() - } - .padding(.horizontal, 4) - .fileImporter( - isPresented: $showFolderPicker, - allowedContentTypes: [.folder], - allowsMultipleSelection: false - ) { result in - if case .success(let urls) = result, let url = urls.first { - if url.startAccessingSecurityScopedResource() { - withAnimation { _ = mcpService.addFolder(url.path) } - url.stopAccessingSecurityScopedResource() - } + Label("Add Folder…", systemImage: "plus") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(Color.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 8)) } + .buttonStyle(.plain) } // Permissions diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index 64dac40..abdd8b6 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -98,6 +98,8 @@ struct oAIApp: App { Button("Save Chat") { chatViewModel.saveFromMenu() } .keyboardShortcut("s", modifiers: .command) .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) + Button("Save Chat As…") { chatViewModel.saveAsFromMenu() } + .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) Button("Stats") { chatViewModel.showStats = true } .keyboardShortcut("s", modifiers: [.command, .shift]) }