diff --git a/oAI/Services/ConversationMergeService.swift b/oAI/Services/ConversationMergeService.swift
new file mode 100644
index 0000000..be26b29
--- /dev/null
+++ b/oAI/Services/ConversationMergeService.swift
@@ -0,0 +1,178 @@
+//
+// ConversationMergeService.swift
+// oAI
+//
+// Combine multiple saved conversations into one (simple concatenation or AI-assisted merge)
+//
+// 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 Foundation
+import os
+
+enum CombineMode: String, Sendable {
+ case simple
+ case ai
+}
+
+enum MergeError: LocalizedError {
+ case tooFewConversations
+ case noDefaultModel
+ case noAPIKey
+ case invalidAIResponse(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .tooFewConversations:
+ return "Select at least two conversations to combine."
+ case .noDefaultModel:
+ return "No default model is configured. Set one in Settings → General → Default Model."
+ case .noAPIKey:
+ return "No API key configured for the default provider. Add one in Settings."
+ case .invalidAIResponse(let snippet):
+ return "The model's response could not be parsed into a conversation: \(snippet)"
+ }
+ }
+}
+
+enum ConversationMergeService {
+
+ static func merge(
+ conversationIds: [UUID],
+ name: String,
+ mode: CombineMode,
+ deleteOriginals: Bool
+ ) async throws -> Conversation {
+ guard conversationIds.count >= 2 else {
+ throw MergeError.tooFewConversations
+ }
+
+ let sources: [(Conversation, [Message])] = try conversationIds.compactMap { id in
+ try DatabaseService.shared.loadConversation(id: id)
+ }
+
+ let mergedMessages: [Message]
+ switch mode {
+ case .simple:
+ mergedMessages = simpleMerge(sources)
+ case .ai:
+ mergedMessages = try await aiMerge(sources)
+ }
+
+ let newConversation = try DatabaseService.shared.saveConversation(name: name, messages: mergedMessages)
+
+ if deleteOriginals {
+ for id in conversationIds {
+ _ = try? DatabaseService.shared.deleteConversation(id: id)
+ }
+ }
+
+ Log.db.info("Combined \(conversationIds.count) conversations into '\(name)' (mode: \(mode.rawValue), deleteOriginals: \(deleteOriginals))")
+
+ return newConversation
+ }
+
+ private static func simpleMerge(_ sources: [(Conversation, [Message])]) -> [Message] {
+ sources.flatMap { $0.1 }.sorted { $0.timestamp < $1.timestamp }
+ }
+
+ private struct MergedTurn: Codable {
+ let role: String
+ let content: String
+ }
+
+ private static func aiMerge(_ sources: [(Conversation, [Message])]) async throws -> [Message] {
+ let settings = SettingsService.shared
+ guard let modelId = settings.defaultModel, !modelId.isEmpty else {
+ throw MergeError.noDefaultModel
+ }
+ guard let provider = ProviderRegistry.shared.getProvider(for: settings.defaultProvider) else {
+ throw MergeError.noAPIKey
+ }
+
+ let transcript = sources.map { conversation, messages -> String in
+ let body = messages.map { msg -> String in
+ let label = msg.role == .user ? "**User:**" : "**Assistant:**"
+ return "\(label) \(msg.content)"
+ }.joined(separator: "\n\n")
+ return "### Conversation: \(conversation.name)\n\n\(body)"
+ }.joined(separator: "\n\n---\n\n")
+
+ let mergePrompt = """
+ Merge the following saved conversation transcripts into a single, coherent conversation. \
+ Remove redundant or duplicate exchanges, keep the most informative answer when sources overlap, \
+ preserve important details from each source, and do not invent facts that were not in the originals.
+
+ Respond with ONLY a JSON array of message objects in logical order, each in the form \
+ {"role": "user" or "assistant", "content": "..."}. Do not include any text outside the JSON array.
+
+ \(transcript)
+ """
+
+ let request = ChatRequest(
+ messages: [Message(role: .user, content: mergePrompt)],
+ model: modelId,
+ stream: false,
+ maxTokens: 4000,
+ temperature: 0.3,
+ topP: nil,
+ systemPrompt: "You are a helpful assistant that merges chat conversation transcripts into one clean, coherent conversation.",
+ tools: nil,
+ onlineMode: false,
+ imageGeneration: false
+ )
+
+ let response: ChatResponse
+ do {
+ response = try await provider.chat(request: request)
+ } catch {
+ Log.api.error("Conversation merge AI call failed: \(error.localizedDescription)")
+ throw error
+ }
+
+ let turns = try parseTurns(from: response.content)
+
+ let base = Date()
+ return turns.enumerated().map { index, turn in
+ Message(
+ role: turn.role == "user" ? .user : .assistant,
+ content: turn.content,
+ timestamp: base.addingTimeInterval(TimeInterval(index)),
+ modelId: modelId
+ )
+ }
+ }
+
+ private static func parseTurns(from raw: String) throws -> [MergedTurn] {
+ var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ if text.hasPrefix("```") {
+ text = text.components(separatedBy: "\n").dropFirst().joined(separator: "\n")
+ if text.hasSuffix("```") {
+ text = String(text.dropLast(3))
+ }
+ text = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ guard let data = text.data(using: .utf8),
+ let turns = try? JSONDecoder().decode([MergedTurn].self, from: data),
+ !turns.isEmpty else {
+ throw MergeError.invalidAIResponse(String(raw.prefix(200)))
+ }
+ return turns
+ }
+}
diff --git a/oAI/Views/Screens/CombineConversationsSheet.swift b/oAI/Views/Screens/CombineConversationsSheet.swift
new file mode 100644
index 0000000..03a0857
--- /dev/null
+++ b/oAI/Views/Screens/CombineConversationsSheet.swift
@@ -0,0 +1,192 @@
+//
+// 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
+ }
+ }
+ }
+ }
+}
diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift
index 5ba8fb2..c35707e 100644
--- a/oAI/Views/Screens/ConversationListView.swift
+++ b/oAI/Views/Screens/ConversationListView.swift
@@ -36,6 +36,7 @@ struct ConversationListView: View {
@State private var semanticResults: [Conversation] = []
@State private var isSearching = false
@State private var selectedIndex: Int = 0
+ @State private var showCombineSheet = false
@FocusState private var searchFocused: Bool
private let settings = SettingsService.shared
var onLoad: ((Conversation) -> Void)?
@@ -70,6 +71,18 @@ struct ConversationListView: View {
}
.buttonStyle(.plain)
+ if selectedConversations.count >= 2 {
+ Button {
+ showCombineSheet = true
+ } label: {
+ HStack(spacing: 4) {
+ Image(systemName: "arrow.triangle.merge")
+ Text("Combine (\(selectedConversations.count))")
+ }
+ }
+ .buttonStyle(.plain)
+ }
+
if !selectedConversations.isEmpty {
Button(role: .destructive) {
deleteSelected()
@@ -298,6 +311,16 @@ struct ConversationListView: View {
searchFocused = true
}
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
+ .sheet(isPresented: $showCombineSheet) {
+ CombineConversationsSheet(
+ conversations: conversations.filter { selectedConversations.contains($0.id) },
+ onCompleted: { _ in
+ loadConversations()
+ selectedConversations.removeAll()
+ isSelecting = false
+ }
+ )
+ }
}
private func loadConversations() {