diff --git a/oAI/Services/ConversationMergeService.swift b/oAI/Services/ConversationMergeService.swift index b5d4e4f..1e2fbd1 100644 --- a/oAI/Services/ConversationMergeService.swift +++ b/oAI/Services/ConversationMergeService.swift @@ -55,6 +55,8 @@ enum ConversationMergeService { conversationIds: [UUID], name: String, mode: CombineMode, + mergeModelId: String? = nil, + mergeProvider: Settings.Provider? = nil, deleteOriginals: Bool ) async throws -> Conversation { guard conversationIds.count >= 2 else { @@ -78,7 +80,7 @@ enum ConversationMergeService { case .simple: mergedMessages = simpleMerge(sources) case .ai: - mergedMessages = try await aiMerge(sources) + mergedMessages = try await aiMerge(sources, modelId: mergeModelId, provider: mergeProvider) } let newConversation = try DatabaseService.shared.saveConversation( @@ -108,12 +110,16 @@ enum ConversationMergeService { let content: String } - private static func aiMerge(_ sources: [(Conversation, [Message])]) async throws -> [Message] { + private static func aiMerge( + _ sources: [(Conversation, [Message])], + modelId explicitModelId: String?, + provider explicitProvider: Settings.Provider? + ) async throws -> [Message] { let settings = SettingsService.shared - guard let modelId = settings.defaultModel, !modelId.isEmpty else { + guard let modelId = explicitModelId ?? settings.defaultModel, !modelId.isEmpty else { throw MergeError.noDefaultModel } - guard let provider = ProviderRegistry.shared.getProvider(for: settings.defaultProvider) else { + guard let provider = ProviderRegistry.shared.getProvider(for: explicitProvider ?? settings.defaultProvider) else { throw MergeError.noAPIKey } diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index ea5d112..c79d665 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -645,9 +645,9 @@ final class DatabaseService: Sendable { nonisolated func listFolders() throws -> [Folder] { try dbQueue.read { db in - let records = try FolderRecord - .order(Column("sortOrder")) - .fetchAll(db) + // Alphabetical, not creation order (sortOrder) — folders should sort + // predictably by name everywhere they're listed. + let records = try FolderRecord.fetchAll(db, sql: "SELECT * FROM folders ORDER BY name COLLATE NOCASE") return records.compactMap { record -> Folder? in guard let id = UUID(uuidString: record.id), let createdAt = Self.isoDate(from: record.createdAt) diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift index 5fcfe72..cbe69a0 100644 --- a/oAI/Views/Main/SidebarView.swift +++ b/oAI/Views/Main/SidebarView.swift @@ -357,6 +357,7 @@ struct SidebarView: View { do { let folder = try DatabaseService.shared.createFolder(name: name) folders.append(folder) + sortFolders() if let conversation = conversation { moveConversation(conversation, toFolder: folder.id) } @@ -366,6 +367,10 @@ struct SidebarView: View { #endif } + private func sortFolders() { + folders.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + private func renameFolderPrompt(_ folder: Folder) { #if os(macOS) let alert = NSAlert() @@ -385,6 +390,7 @@ struct SidebarView: View { if let i = folders.firstIndex(where: { $0.id == folder.id }) { folders[i].name = newName } + sortFolders() } catch { Log.db.error("Failed to rename folder: \(error.localizedDescription)") } diff --git a/oAI/Views/Screens/CombineConversationsSheet.swift b/oAI/Views/Screens/CombineConversationsSheet.swift index 39ab75a..5561ae8 100644 --- a/oAI/Views/Screens/CombineConversationsSheet.swift +++ b/oAI/Views/Screens/CombineConversationsSheet.swift @@ -25,6 +25,7 @@ import SwiftUI struct CombineConversationsSheet: View { @Environment(\.dismiss) var dismiss + @Environment(ChatViewModel.self) private var chatViewModel let conversations: [Conversation] var onCompleted: (Conversation) -> Void @@ -34,6 +35,9 @@ struct CombineConversationsSheet: View { @State private var deleteOriginals = false @State private var isProcessing = false @State private var errorMessage: String? + @State private var mergeModel: ModelInfo? + @State private var mergeProvider: Settings.Provider + @State private var showModelPicker = false private let settings = SettingsService.shared @@ -42,9 +46,13 @@ struct CombineConversationsSheet: View { self.onCompleted = onCompleted let joined = conversations.map(\.name).joined(separator: " + ") _name = State(initialValue: String(joined.prefix(80))) + _mergeProvider = State(initialValue: SettingsService.shared.defaultProvider) } - private var defaultModelLabel: String? { + private var mergeModelLabel: String? { + if let mergeModel { + return "\(mergeProvider.displayName) / \(mergeModel.name)" + } guard let model = settings.defaultModel, !model.isEmpty else { return nil } return "\(settings.defaultProvider.displayName) / \(model)" } @@ -52,7 +60,7 @@ struct CombineConversationsSheet: View { private var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && conversations.count >= 2 - && (mode == .simple || defaultModelLabel != nil) + && (mode == .simple || mergeModelLabel != nil) } var body: some View { @@ -109,12 +117,20 @@ struct CombineConversationsSheet: View { } else { Text("A model reads all the source messages and rewrites them into one coherent, de-duplicated conversation.") .font(.caption).foregroundStyle(.secondary) - if let label = defaultModelLabel { - Label("Uses your default model: \(label)", systemImage: "cpu") - .font(.caption).foregroundStyle(.secondary) - } else { - Label("No default model configured — set one in Settings → General.", systemImage: "exclamationmark.triangle.fill") - .font(.caption).foregroundStyle(.orange) + HStack(spacing: 8) { + if let label = mergeModelLabel { + Label(label, systemImage: "cpu") + .font(.caption).foregroundStyle(.secondary) + } else { + Label("No model selected", systemImage: "exclamationmark.triangle.fill") + .font(.caption).foregroundStyle(.orange) + } + Button("Change…") { + showModelPicker = true + } + .buttonStyle(.link) + .font(.caption) + .disabled(isProcessing) } } } @@ -156,6 +172,25 @@ struct CombineConversationsSheet: View { .padding(.horizontal, 24).padding(.vertical, 12) } .frame(minWidth: 520, idealWidth: 560, minHeight: 460, idealHeight: 520) + .task { + if chatViewModel.availableModels.count <= 10 { + await chatViewModel.loadAvailableModels() + } + if mergeModel == nil, let defaultModel = settings.defaultModel { + mergeModel = chatViewModel.availableModels.first(where: { $0.id == defaultModel }) + } + } + .sheet(isPresented: $showModelPicker) { + ModelSelectorView( + models: chatViewModel.availableModels, + selectedModel: mergeModel, + onSelect: { model in + mergeModel = model + mergeProvider = chatViewModel.inferProviderPublic(from: model.id) ?? mergeProvider + showModelPicker = false + } + ) + } } private func combine() { @@ -165,6 +200,8 @@ struct CombineConversationsSheet: View { let trimmedName = name.trimmingCharacters(in: .whitespaces) let selectedMode = mode let shouldDeleteOriginals = deleteOriginals + let selectedModelId = mergeModel?.id + let selectedProvider = mergeModel != nil ? mergeProvider : nil Task { do { @@ -172,6 +209,8 @@ struct CombineConversationsSheet: View { conversationIds: ids, name: trimmedName, mode: selectedMode, + mergeModelId: selectedModelId, + mergeProvider: selectedProvider, deleteOriginals: shouldDeleteOriginals ) await MainActor.run { diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index 9064d30..42d6290 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -479,12 +479,17 @@ struct ConversationListView: View { do { let folder = try DatabaseService.shared.createFolder(name: name) folders.append(folder) + sortFolders() } catch { Log.db.error("Failed to create folder: \(error.localizedDescription)") } #endif } + private func sortFolders() { + folders.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + private func loadConversations() { do { conversations = try DatabaseService.shared.listConversations() @@ -525,6 +530,7 @@ struct ConversationListView: View { if let i = folders.firstIndex(where: { $0.id == folder.id }) { folders[i].name = newName } + sortFolders() } catch { Log.db.error("Failed to rename folder: \(error.localizedDescription)") } diff --git a/oAITests/DatabaseServiceTests.swift b/oAITests/DatabaseServiceTests.swift index 1a22d65..540af09 100644 --- a/oAITests/DatabaseServiceTests.swift +++ b/oAITests/DatabaseServiceTests.swift @@ -240,14 +240,15 @@ struct DatabaseServiceFolderTests { #expect(second.sortOrder == 1) } - @Test("listFolders returns folders ordered by sortOrder") + @Test("listFolders returns folders sorted alphabetically, case-insensitive, regardless of creation order") func listFoldersOrdered() throws { let db = DatabaseService.makeInMemory() _ = try db.createFolder(name: "Work") + _ = try db.createFolder(name: "apple") _ = try db.createFolder(name: "Personal") let folders = try db.listFolders() - #expect(folders.map(\.name) == ["Work", "Personal"]) + #expect(folders.map(\.name) == ["apple", "Personal", "Work"]) } @Test("renameFolder updates the stored name")