// // ConversationListView.swift // oAI // // Saved conversations list // 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 = [] @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 @FocusState private var searchFocused: Bool private let settings = SettingsService.shared var onLoad: ((Conversation) -> 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.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 { 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 { 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) } 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 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() }