Files
oai-swift/oAI/Views/Screens/CombineConversationsSheet.swift
T
rune 72540305ad Fix deletions not sticking (git sync resurrection), persist folder
collapse state, make merge provider picker visibly clickable

Deleted conversations coming back: exportAllConversations() only ever
wrote files for conversations that currently exist — it never removed
the exported markdown file for a conversation that had been deleted
locally. That file just sits in the sync repo forever, so every
future pull+import (including on every app startup) silently
resurrects it, since importAllConversations() only skips an import
when a matching local ID already exists. Fixed by having export also
delete orphaned files (conversation ID no longer present locally), and
added GitSyncService.syncAfterDeletion() — a debounced export+push
triggered right after any delete/bulk-delete/merge-cleanup, so the
removal reaches the remote promptly instead of waiting on an
unrelated future auto-save. Existing duplicates need one more manual
delete to clear, but they'll stay gone after that.

Folder collapse state now persists (SettingsService.collapsedFolderIds,
JSON-encoded like favoriteModelIds) and is restored on app launch, in
both the sidebar and the advanced conversation list.

Merge model picker: the provider switcher was legitimate (it does load
each provider's own catalog independently) but looked like plain
text — no chevron, no button styling — so it wasn't obviously
clickable. Restyled to match HeaderView's provider menu affordance
(icon + label + chevron on a colored pill).
2026-07-28 15:21:11 +02:00

284 lines
12 KiB
Swift

//
// CombineConversationsSheet.swift
// oAI
//
// Combine 2+ saved conversations into one, optionally using AI to merge content
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
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?
@State private var mergeModel: ModelInfo?
@State private var mergeProvider: Settings.Provider
@State private var mergeModels: [ModelInfo] = []
@State private var isLoadingMergeModels = false
@State private var showModelPicker = false
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)))
_mergeProvider = State(initialValue: SettingsService.shared.defaultProvider)
}
private var mergeModelLabel: String? {
guard let mergeModel else { return nil }
return "\(mergeProvider.displayName) / \(mergeModel.name)"
}
private var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty
&& conversations.count >= 2
&& (mode == .simple || mergeModelLabel != 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)
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)
}
Menu {
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { p in
Button {
switchMergeProvider(to: p)
} label: {
HStack {
Image(systemName: p.iconName)
Text(p.displayName)
if p == mergeProvider { Image(systemName: "checkmark") }
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: mergeProvider.iconName)
Text(mergeProvider.displayName)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 7))
.opacity(0.7)
}
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.providerColor(mergeProvider))
.cornerRadius(4)
}
.menuStyle(.borderlessButton)
.fixedSize()
.disabled(isProcessing || isLoadingMergeModels)
Button("Change Model…") {
showModelPicker = true
}
.buttonStyle(.link)
.font(.caption)
.disabled(isProcessing || isLoadingMergeModels || mergeModels.isEmpty)
if isLoadingMergeModels {
ProgressView().controlSize(.mini)
}
}
}
}
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)
.task {
await loadMergeModels()
if let defaultModel = settings.defaultModel {
mergeModel = mergeModels.first(where: { $0.id == defaultModel })
}
}
.sheet(isPresented: $showModelPicker) {
ModelSelectorView(
models: mergeModels,
selectedModel: mergeModel,
onSelect: { model in
mergeModel = model
showModelPicker = false
}
)
}
}
private func switchMergeProvider(to newProvider: Settings.Provider) {
guard newProvider != mergeProvider else { return }
mergeProvider = newProvider
mergeModel = nil
mergeModels = []
Task { await loadMergeModels() }
}
private func loadMergeModels() async {
guard let provider = ProviderRegistry.shared.getProvider(for: mergeProvider) else {
mergeModels = []
return
}
isLoadingMergeModels = true
defer { isLoadingMergeModels = false }
do {
mergeModels = try await provider.listModels()
} catch {
Log.api.error("Failed to load models for merge provider \(mergeProvider.rawValue): \(error.localizedDescription)")
mergeModels = []
}
}
private func combine() {
isProcessing = true
errorMessage = nil
let ids = conversations.map(\.id)
let trimmedName = name.trimmingCharacters(in: .whitespaces)
let selectedMode = mode
let shouldDeleteOriginals = deleteOriginals
let selectedModelId = mergeModel?.id
let selectedProvider = mergeModel != nil ? mergeProvider : nil
Task {
do {
let newConversation = try await ConversationMergeService.merge(
conversationIds: ids,
name: trimmedName,
mode: selectedMode,
mergeModelId: selectedModelId,
mergeProvider: selectedProvider,
deleteOriginals: shouldDeleteOriginals
)
await MainActor.run {
isProcessing = false
onCompleted(newConversation)
dismiss()
}
} catch {
await MainActor.run {
isProcessing = false
errorMessage = error.localizedDescription
}
}
}
}
}