Files
oai-swift/oAI/Views/Screens/ConversationListView.swift
T
runeandClaude Sonnet 5 0af644456e Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:34:04 +02:00

512 lines
20 KiB
Swift

//
// ConversationListView.swift
// oAI
//
// Saved conversations list
//
// 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 os
import SwiftUI
struct ConversationListView: View {
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var conversations: [Conversation] = []
@State private var selectedConversations: Set<UUID> = []
@State private var isSelecting = false
@State private var useSemanticSearch = false
@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)?
var onRename: ((UUID, String) -> Void)?
private var filteredConversations: [Conversation] {
if searchText.isEmpty {
return conversations
}
if useSemanticSearch && settings.embeddingsEnabled {
return semanticResults
} else {
return conversations.filter {
$0.name.lowercased().contains(searchText.lowercased())
}
}
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Conversations")
.font(.system(size: 18, weight: .bold))
Spacer()
if isSelecting {
Button("Cancel") {
isSelecting = false
selectedConversations.removeAll()
}
.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()
} label: {
HStack(spacing: 4) {
Image(systemName: "trash")
Text("Delete (\(selectedConversations.count))")
}
}
.buttonStyle(.plain)
.foregroundStyle(.red)
}
} else {
if !conversations.isEmpty {
Button("Select") {
isSelecting = true
}
.buttonStyle(.plain)
}
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search conversations...", text: $searchText)
.textFieldStyle(.plain)
.focused($searchFocused)
.onChange(of: searchText) {
selectedIndex = 0
if useSemanticSearch && settings.embeddingsEnabled && !searchText.isEmpty {
performSemanticSearch()
}
}
#if os(macOS)
.onKeyPress(.upArrow) {
if selectedIndex > 0 {
selectedIndex -= 1
}
return .handled
}
.onKeyPress(.downArrow) {
if selectedIndex < filteredConversations.count - 1 {
selectedIndex += 1
}
return .handled
}
.onKeyPress(.return, phases: .down) { _ in
guard !isSelecting, !filteredConversations.isEmpty else { return .ignored }
let conv = filteredConversations[min(selectedIndex, filteredConversations.count - 1)]
onLoad?(conv)
dismiss()
return .handled
}
#endif
if !searchText.isEmpty {
Button { searchText = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
if settings.embeddingsEnabled {
Divider()
.frame(height: 16)
Toggle("Semantic", isOn: $useSemanticSearch)
.toggleStyle(.switch)
.controlSize(.small)
.onChange(of: useSemanticSearch) {
if useSemanticSearch && !searchText.isEmpty {
performSemanticSearch()
}
}
.help("Use AI-powered semantic search instead of keyword matching")
}
if isSearching {
ProgressView()
.controlSize(.small)
}
}
.padding(10)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 24)
.padding(.bottom, 12)
Divider()
// Content
if filteredConversations.isEmpty {
Spacer()
VStack(spacing: 8) {
Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass")
.font(.largeTitle)
.foregroundStyle(.tertiary)
Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches")
.font(.headline)
.foregroundStyle(.secondary)
Text(searchText.isEmpty ? "Conversations you save will appear here" : "Try a different search term")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer()
} else {
ScrollViewReader { proxy in
List {
ForEach(Array(filteredConversations.enumerated()), id: \.element.id) { index, conversation in
HStack(spacing: 12) {
if isSelecting {
Button {
toggleSelection(conversation.id)
} label: {
Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary)
.font(.title2)
}
.buttonStyle(.plain)
}
ConversationRow(conversation: conversation)
.contentShape(Rectangle())
.onTapGesture {
if isSelecting {
toggleSelection(conversation.id)
} else {
selectedIndex = index
onLoad?(conversation)
dismiss()
}
}
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: {
Image(systemName: "trash")
.foregroundStyle(.red)
.font(.system(size: 16))
}
.buttonStyle(.plain)
.help("Delete conversation")
}
}
.listRowBackground(
!isSelecting && index == selectedIndex
? Color.oaiAccent.opacity(0.15)
: Color.clear
)
.id(conversation.id)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
deleteConversation(conversation)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
renameConversation(conversation)
} label: {
Label("Rename", systemImage: "pencil")
}
.tint(.orange)
Button {
exportConversation(conversation)
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
.tint(.blue)
}
}
}
.listStyle(.plain)
.onChange(of: selectedIndex) {
guard !filteredConversations.isEmpty else { return }
let clamped = min(selectedIndex, filteredConversations.count - 1)
withAnimation(.easeInOut(duration: 0.1)) {
proxy.scrollTo(filteredConversations[clamped].id, anchor: .center)
}
}
}
}
Divider()
// Bottom bar
HStack {
Text("↑↓ navigate ↩ open")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
Spacer()
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.controlSize(.regular)
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.onAppear {
loadConversations()
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() {
do {
conversations = try DatabaseService.shared.listConversations()
} catch {
Log.db.error("Failed to load conversations: \(error.localizedDescription)")
conversations = []
}
}
private func toggleSelection(_ id: UUID) {
if selectedConversations.contains(id) {
selectedConversations.remove(id)
} else {
selectedConversations.insert(id)
}
}
private func deleteSelected() {
for id in selectedConversations {
do {
let _ = try DatabaseService.shared.deleteConversation(id: id)
} catch {
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
}
}
withAnimation {
conversations.removeAll { selectedConversations.contains($0.id) }
selectedConversations.removeAll()
isSelecting = false
}
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)
withAnimation {
conversations.removeAll { $0.id == conversation.id }
}
selectedIndex = min(selectedIndex, max(0, filteredConversations.count - 1))
} catch {
Log.db.error("Failed to delete conversation: \(error.localizedDescription)")
}
}
private func performSemanticSearch() {
guard !searchText.isEmpty else {
semanticResults = []
return
}
isSearching = true
Task {
do {
guard let provider = EmbeddingService.shared.getSelectedProvider() else {
Log.api.warning("No embedding providers available - skipping semantic search")
await MainActor.run {
isSearching = false
}
return
}
let embedding = try await EmbeddingService.shared.generateEmbedding(
text: searchText,
provider: provider
)
let results = try DatabaseService.shared.searchConversationsBySemantic(
queryEmbedding: embedding,
limit: 20
)
await MainActor.run {
semanticResults = results.map { $0.0 }
selectedIndex = 0
isSearching = false
Log.ui.info("Semantic search found \(results.count) results using \(provider.displayName)")
}
} catch {
await MainActor.run {
semanticResults = []
isSearching = false
Log.ui.error("Semantic search failed: \(error)")
}
}
}
}
private func exportConversation(_ conversation: Conversation) {
guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id),
!loadedMessages.isEmpty else {
return
}
let content = loadedMessages.map { msg in
let header = msg.role == .user ? "**User**" : "**Assistant**"
return "\(header)\n\n\(msg.content)"
}.joined(separator: "\n\n---\n\n")
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let filename = conversation.name.replacingOccurrences(of: " ", with: "_") + ".md"
let fileURL = downloads.appendingPathComponent(filename)
try? content.write(to: fileURL, atomically: true, encoding: .utf8)
}
}
struct ConversationRow: View {
let conversation: Conversation
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "dd.MM.yyyy HH:mm"
return formatter.string(from: conversation.updatedAt)
}
/// Strips the provider prefix from OpenRouter-style IDs (e.g. "anthropic/claude-3" → "claude-3")
private var modelDisplayName: String? {
guard let model = conversation.primaryModel, !model.isEmpty else { return nil }
if let slash = model.lastIndex(of: "/") {
return String(model[model.index(after: slash)...])
}
return model
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(conversation.name)
.font(.system(size: 15, weight: .semibold))
.lineLimit(1)
HStack(spacing: 6) {
Label("\(conversation.messageCount)", systemImage: "message")
.font(.system(size: 12))
Text("•")
.font(.system(size: 12))
Text(formattedDate)
.font(.system(size: 12))
if let model = modelDisplayName {
Text("•")
.font(.system(size: 12))
Text(model)
.font(.system(size: 12))
.lineLimit(1)
.truncationMode(.middle)
}
}
.foregroundColor(.secondary)
}
.padding(.vertical, 5)
}
}
#Preview {
ConversationListView()
}