// // ConversationListView.swift // Confab // // Saved conversations list // // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen // // This file is part of Confab. // // Confab 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 Confab 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 // for // the full license text. For commercial licensing, contact Rune // Olsen via . import os import SwiftUI struct ConversationListView: View { @Environment(\.dismiss) var dismiss @State private var searchText = "" @State private var conversations: [Conversation] = [] @State private var folders: [Folder] = [] @State private var collapsedFolders: Set = [] @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 @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()) } } } private var conversationsByFolder: [UUID?: [Conversation]] { Dictionary(grouping: filteredConversations, by: { $0.folderId }) } 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 { Button { createFolderPrompt() } label: { Label("New Folder", systemImage: "folder.badge.plus") } .buttonStyle(.plain) 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 { if folders.isEmpty { ForEach(filteredConversations) { conversation in conversationRow(conversation) } } else { ForEach(folders) { folder in let folderConversations = conversationsByFolder[folder.id] ?? [] if !folderConversations.isEmpty || searchText.isEmpty { Section { if !collapsedFolders.contains(folder.id) { ForEach(folderConversations) { conversation in conversationRow(conversation) } } } header: { folderHeader(folder) } } } let unfiled = conversationsByFolder[nil] ?? [] if !unfiled.isEmpty { Section { ForEach(unfiled) { conversation in conversationRow(conversation) } } header: { Text("Unfiled") .dropDestination(for: String.self) { items, _ in _ = handleDrop(items, toFolder: nil) } } } } } .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 collapsedFolders = SettingsService.shared.collapsedFolderIds } .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 } ) } } @ViewBuilder private func conversationRow(_ conversation: Conversation) -> some View { let index = filteredConversations.firstIndex(where: { $0.id == conversation.id }) ?? 0 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.confabAccent.opacity(0.15) : Color.clear ) .id(conversation.id) .draggable(conversation.id.uuidString) { Text(conversation.name) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6)) } .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) } .contextMenu { Menu { if conversation.folderId != nil { Button { moveConversation(conversation, toFolder: nil) } label: { Label("Remove from Folder", systemImage: "folder.badge.minus") } Divider() } ForEach(folders) { folder in if folder.id != conversation.folderId { Button { moveConversation(conversation, toFolder: folder.id) } label: { Text(folder.name) } } } } label: { Label("Move to Folder", systemImage: "folder") } Menu { Button { exportConversation(conversation, format: "md") } label: { Label("Markdown", systemImage: "doc.text") } Button { exportConversation(conversation, format: "html") } label: { Label("HTML", systemImage: "chevron.left.forwardslash.chevron.right") } Button { exportConversation(conversation, format: "pdf") } label: { Label("PDF", systemImage: "doc.richtext") } } label: { Label("Export", systemImage: "square.and.arrow.up") } Button { renameConversation(conversation) } label: { Label("Rename", systemImage: "pencil") } Button(role: .destructive) { deleteConversation(conversation) } label: { Label("Delete", systemImage: "trash") } } } @ViewBuilder private func folderHeader(_ folder: Folder) -> some View { HStack(spacing: 4) { Image(systemName: "chevron.right") .font(.system(size: 9, weight: .bold)) .rotationEffect(.degrees(collapsedFolders.contains(folder.id) ? 0 : 90)) Text(folder.name) .font(.system(size: 12, weight: .bold)) } .contentShape(Rectangle()) .onTapGesture { withAnimation(.easeInOut(duration: 0.15)) { toggleCollapsed(folder.id) } } .contextMenu { Button { renameFolderPrompt(folder) } label: { Label("Rename Folder", systemImage: "pencil") } Button(role: .destructive) { deleteFolder(folder) } label: { Label("Delete Folder", systemImage: "trash") } } .dropDestination(for: String.self) { items, _ in _ = handleDrop(items, toFolder: folder.id) } } private func toggleCollapsed(_ folderId: UUID) { if collapsedFolders.contains(folderId) { collapsedFolders.remove(folderId) } else { collapsedFolders.insert(folderId) } SettingsService.shared.collapsedFolderIds = collapsedFolders } private func handleDrop(_ items: [String], toFolder folderId: UUID?) -> Bool { var moved = false for idString in items { guard let id = UUID(uuidString: idString), let conversation = conversations.first(where: { $0.id == id }) else { continue } moveConversation(conversation, toFolder: folderId) moved = true } return moved } private func createFolderPrompt() { #if os(macOS) let alert = NSAlert() alert.messageText = "New Folder" alert.addButton(withTitle: "Create") alert.addButton(withTitle: "Cancel") let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) alert.accessoryView = input alert.window.initialFirstResponder = input guard alert.runModal() == .alertFirstButtonReturn else { return } let name = input.stringValue.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } do { let folder = try DatabaseService.shared.createFolder(name: name) folders.append(folder) sortFolders() } catch { Log.db.error("Failed to create folder: \(error.localizedDescription)") } #endif } private func sortFolders() { folders.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } private func loadConversations() { do { conversations = try DatabaseService.shared.listConversations() folders = try DatabaseService.shared.listFolders() } catch { Log.db.error("Failed to load conversations: \(error.localizedDescription)") conversations = [] } } private func moveConversation(_ conversation: Conversation, toFolder folderId: UUID?) { do { try DatabaseService.shared.moveConversation(id: conversation.id, toFolder: folderId) if let i = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[i].folderId = folderId } } catch { Log.db.error("Failed to move conversation: \(error.localizedDescription)") } } private func renameFolderPrompt(_ folder: Folder) { #if os(macOS) let alert = NSAlert() alert.messageText = "Rename Folder" alert.addButton(withTitle: "Rename") alert.addButton(withTitle: "Cancel") let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) input.stringValue = folder.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 != folder.name else { return } do { try DatabaseService.shared.renameFolder(id: folder.id, name: newName) if let i = folders.firstIndex(where: { $0.id == folder.id }) { folders[i].name = newName } sortFolders() } catch { Log.db.error("Failed to rename folder: \(error.localizedDescription)") } #endif } private func deleteFolder(_ folder: Folder) { do { try DatabaseService.shared.deleteFolder(id: folder.id) folders.removeAll { $0.id == folder.id } for i in conversations.indices where conversations[i].folderId == folder.id { conversations[i].folderId = nil } } catch { Log.db.error("Failed to delete folder: \(error.localizedDescription)") } } 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 GitSyncService.shared.syncAfterDeletion() } 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)) GitSyncService.shared.syncAfterDeletion() } 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, format: String = "md") { guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id), !loadedMessages.isEmpty else { return } let baseName = conversation.name.replacingOccurrences(of: " ", with: "_") if format == "pdf" { Task { @MainActor in guard let data = try? await ConversationExportService.pdfData(name: conversation.name, messages: loadedMessages) else { return } _ = ConversationExportService.writeToDownloads(data, filename: baseName + ".pdf") } return } let content: String let filename: String switch format { case "html": content = ConversationExportService.html(name: conversation.name, messages: loadedMessages) filename = baseName + ".html" default: content = ConversationExportService.markdown(messages: loadedMessages) filename = baseName + ".md" } _ = ConversationExportService.writeToDownloads(content, filename: filename) } } 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)) .foregroundStyle(.primary) .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() }