// // CombineConversationsSheet.swift // oAI // // Combine 2+ saved conversations into one, optionally using AI to merge content // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . import SwiftUI struct CombineConversationsSheet: View { @Environment(\.dismiss) var dismiss let conversations: [Conversation] var onCompleted: (Conversation) -> Void @State private var name: String @State private var mode: CombineMode = .simple @State private var deleteOriginals = false @State private var isProcessing = false @State private var errorMessage: String? private let settings = SettingsService.shared init(conversations: [Conversation], onCompleted: @escaping (Conversation) -> Void) { self.conversations = conversations self.onCompleted = onCompleted let joined = conversations.map(\.name).joined(separator: " + ") _name = State(initialValue: String(joined.prefix(80))) } private var defaultModelLabel: String? { guard let model = settings.defaultModel, !model.isEmpty else { return nil } return "\(settings.defaultProvider.displayName) / \(model)" } private var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && conversations.count >= 2 && (mode == .simple || defaultModelLabel != nil) } var body: some View { VStack(spacing: 0) { HStack { Text("Combine Conversations") .font(.system(size: 18, weight: .bold)) Spacer() Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .font(.title2).foregroundStyle(.secondary) } .buttonStyle(.plain) .keyboardShortcut(.escape, modifiers: []) .disabled(isProcessing) } .padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 16) Divider() ScrollView { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text("Combining \(conversations.count) conversations").font(.system(size: 13, weight: .semibold)) VStack(alignment: .leading, spacing: 4) { ForEach(conversations) { conversation in Label("\(conversation.name) (\(conversation.messageCount) messages)", systemImage: "bubble.left.and.bubble.right") .font(.system(size: 12)) .foregroundStyle(.secondary) } } } VStack(alignment: .leading, spacing: 6) { Text("New conversation name").font(.system(size: 13, weight: .semibold)) TextField("Name", text: $name) .textFieldStyle(.roundedBorder) .disabled(isProcessing) } VStack(alignment: .leading, spacing: 8) { Text("Merge method").font(.system(size: 13, weight: .semibold)) Picker("", selection: $mode) { Text("Simple Merge").tag(CombineMode.simple) Text("AI-Assisted Merge").tag(CombineMode.ai) } .pickerStyle(.segmented) .labelsHidden() .disabled(isProcessing) if mode == .simple { Text("Messages from all selected conversations are combined in chronological order.") .font(.caption).foregroundStyle(.secondary) } 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) } } } Toggle("Delete original conversations after combining", isOn: $deleteOriginals) .toggleStyle(.checkbox) .disabled(isProcessing) if let errorMessage { HStack(alignment: .top, spacing: 8) { Image(systemName: "xmark.octagon.fill").foregroundStyle(.red) Text(errorMessage).font(.caption) } .padding(10) .background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) } } .padding(.horizontal, 24).padding(.vertical, 16) } Divider() HStack { Button("Cancel") { dismiss() } .buttonStyle(.bordered) .disabled(isProcessing) Spacer() if isProcessing { ProgressView().controlSize(.small) Text("Combining…").font(.caption).foregroundStyle(.secondary) } Button("Combine") { combine() } .buttonStyle(.borderedProminent) .disabled(!isValid || isProcessing) .keyboardShortcut(.return, modifiers: [.command]) } .padding(.horizontal, 24).padding(.vertical, 12) } .frame(minWidth: 520, idealWidth: 560, minHeight: 460, idealHeight: 520) } private func combine() { isProcessing = true errorMessage = nil let ids = conversations.map(\.id) let trimmedName = name.trimmingCharacters(in: .whitespaces) let selectedMode = mode let shouldDeleteOriginals = deleteOriginals Task { do { let newConversation = try await ConversationMergeService.merge( conversationIds: ids, name: trimmedName, mode: selectedMode, deleteOriginals: shouldDeleteOriginals ) await MainActor.run { isProcessing = false onCompleted(newConversation) dismiss() } } catch { await MainActor.run { isProcessing = false errorMessage = error.localizedDescription } } } } }