Files
oai-swift/oAI/Views/Screens/ConversationListView.swift
T
rune e2284aba2b Show manual sync-conflict fix instructions in-app instead of deep-linking to Help
NSWorkspace.shared.open() silently drops #fragment anchors on file://
URLs, so "Fix It Myself" always landed on the Help Book index instead
of the relevant section. Replaced with GitSyncManualFixSheet, an
in-app sheet showing the real conflicting filenames and sync path.

Also indent conversation rows one level deeper than their containing
folder in the sidebar and conversation list, so nesting is visible on
the conversations themselves and not just the folder headers.
2026-08-04 12:21:15 +02:00

926 lines
37 KiB
Swift

//
// 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
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
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<UUID> = []
@State private var selectedConversations: Set<UUID> = []
@State private var isSelecting = false
@State private var lastClickedId: UUID? = nil
@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 })
}
private var orderedFolderTree: [(folder: Folder, depth: Int)] { Folder.orderedTree(from: folders) }
private var visibleFolderIds: Set<UUID> { Folder.visibleFolderIds(tree: orderedFolderTree, collapsed: collapsedFolders) }
/// Flattened conversation order matching what's actually rendered in the List — folders in
/// depth-first tree order (skipping collapsed ones' contents, since they're not
/// visible/selectable), then Unfiled last. Used as the anchor sequence for Shift-click range
/// selection.
private var visibleOrderedConversations: [Conversation] {
guard !folders.isEmpty else { return filteredConversations }
var result: [Conversation] = []
for (folder, _) in orderedFolderTree where visibleFolderIds.contains(folder.id) && !collapsedFolders.contains(folder.id) {
result.append(contentsOf: conversationsByFolder[folder.id] ?? [])
}
result.append(contentsOf: conversationsByFolder[nil] ?? [])
return result
}
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()
lastClickedId = nil
}
.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 {
Menu {
if !folders.isEmpty {
ForEach(orderedFolderTree, id: \.folder.id) { entry in
Button(String(repeating: " ", count: entry.depth) + entry.folder.name) {
moveSelectedToFolder(entry.folder.id)
}
}
Divider()
}
Button("Remove from Folder") {
moveSelectedToFolder(nil)
}
} label: {
HStack(spacing: 4) {
Image(systemName: "folder")
Text("Move to Folder (\(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(orderedFolderTree, id: \.folder.id) { entry in
if visibleFolderIds.contains(entry.folder.id) {
let folderConversations = conversationsByFolder[entry.folder.id] ?? []
if !folderConversations.isEmpty || searchText.isEmpty {
Section {
if !collapsedFolders.contains(entry.folder.id) {
ForEach(folderConversations) { conversation in
conversationRow(conversation, depth: entry.depth + 1)
}
}
} header: {
folderHeader(entry.folder, depth: entry.depth)
}
}
}
}
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
lastClickedId = nil
}
)
}
}
@ViewBuilder
private func conversationRow(_ conversation: Conversation, depth: Int = 0) -> some View {
let index = filteredConversations.firstIndex(where: { $0.id == conversation.id }) ?? 0
HStack(spacing: 12) {
if isSelecting {
Button {
toggleSelection(conversation.id)
lastClickedId = 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 {
handleRowTap(conversation, index: index)
}
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")
}
}
.padding(.leading, CGFloat(depth) * 14)
.listRowBackground(
!isSelecting && index == selectedIndex
? Color.confabAccent.opacity(0.15)
: Color.clear
)
.id(conversation.id)
.draggable(DraggedItem.conversations(
isSelecting && selectedConversations.contains(conversation.id) && selectedConversations.count > 1
? Array(selectedConversations) : [conversation.id]
).rawValue) {
if isSelecting && selectedConversations.contains(conversation.id) && selectedConversations.count > 1 {
Text("\(selectedConversations.count) conversations")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6))
} else {
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 {
moveConversationOrSelection(conversation, toFolder: nil)
} label: {
Label("Remove from Folder", systemImage: "folder.badge.minus")
}
Divider()
}
ForEach(orderedFolderTree, id: \.folder.id) { entry in
if entry.folder.id != conversation.folderId {
Button {
moveConversationOrSelection(conversation, toFolder: entry.folder.id)
} label: {
Text(String(repeating: " ", count: entry.depth) + entry.folder.name)
}
}
}
} label: {
Label(isSelecting && selectedConversations.contains(conversation.id) && selectedConversations.count > 1
? "Move \(selectedConversations.count) to Folder" : "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, depth: Int) -> 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))
}
.padding(.leading, CGFloat(depth) * 14)
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.15)) {
toggleCollapsed(folder.id)
}
}
.contextMenu {
Button {
createFolderPrompt(parentId: folder.id)
} label: {
Label("New Subfolder…", systemImage: "folder.badge.plus")
}
Button {
renameFolderPrompt(folder)
} label: {
Label("Rename Folder", systemImage: "pencil")
}
Button(role: .destructive) {
deleteFolder(folder)
} label: {
Label("Delete Folder", systemImage: "trash")
}
}
.draggable(DraggedItem.folder(folder.id).rawValue) {
Text(folder.name)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.confabAccent, in: RoundedRectangle(cornerRadius: 6))
}
.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 targetFolderId: UUID?) -> Bool {
var moved = false
for raw in items {
guard let item = DraggedItem(rawValue: raw) else { continue }
switch item {
case .conversations(let ids):
for id in ids {
guard let conversation = conversations.first(where: { $0.id == id }) else { continue }
moveConversation(conversation, toFolder: targetFolderId)
moved = true
}
case .folder(let sourceId):
guard sourceId != targetFolderId else { continue }
if let targetFolderId, Folder.isDescendant(targetFolderId, of: sourceId, in: folders) { continue }
do {
try DatabaseService.shared.moveFolder(id: sourceId, toParent: targetFolderId)
if let i = folders.firstIndex(where: { $0.id == sourceId }) { folders[i].parentId = targetFolderId }
moved = true
} catch {
Log.db.error("Failed to move folder: \(error.localizedDescription)")
}
}
}
return moved
}
private func createFolderPrompt(parentId: UUID? = nil) {
#if os(macOS)
let alert = NSAlert()
alert.messageText = parentId == nil ? "New Folder" : "New Subfolder"
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, parentId: parentId)
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)")
}
}
/// Moves every currently-selected conversation to a folder (or removes them all from their
/// folders if `folderId` is nil).
private func moveSelectedToFolder(_ folderId: UUID?) {
for id in selectedConversations {
guard let conversation = conversations.first(where: { $0.id == id }) else { continue }
moveConversation(conversation, toFolder: folderId)
}
}
/// Right-clicking a conversation that's part of a multi-item selection moves the whole
/// selection; right-clicking a single (non-selected, or lone-selected) row moves just that one.
private func moveConversationOrSelection(_ conversation: Conversation, toFolder folderId: UUID?) {
if isSelecting && selectedConversations.contains(conversation.id) && selectedConversations.count > 1 {
moveSelectedToFolder(folderId)
} else {
moveConversation(conversation, toFolder: folderId)
}
}
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)
// Matches the DB's reparent-up-one-level semantics: children and conversations
// filed directly in this folder move to its own parent (nil if it was top-level),
// not blanket-unfiled.
let parentId = folder.parentId
folders.removeAll { $0.id == folder.id }
for i in folders.indices where folders[i].parentId == folder.id {
folders[i].parentId = parentId
}
for i in conversations.indices where conversations[i].folderId == folder.id {
conversations[i].folderId = parentId
}
} 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)
}
}
/// Standard macOS row-click handling: ⌘-click toggles the individual row (entering selection
/// mode if needed), Shift-click extends/creates a contiguous range from the last-clicked row,
/// and a plain click either toggles (while already selecting) or opens the conversation.
private func handleRowTap(_ conversation: Conversation, index: Int) {
#if os(macOS)
let modifiers = NSEvent.modifierFlags
if modifiers.contains(.command) {
isSelecting = true
toggleSelection(conversation.id)
lastClickedId = conversation.id
return
}
if modifiers.contains(.shift) {
isSelecting = true
selectRange(to: conversation.id)
lastClickedId = conversation.id
return
}
#endif
if isSelecting {
toggleSelection(conversation.id)
lastClickedId = conversation.id
} else {
selectedIndex = index
onLoad?(conversation)
dismiss()
}
}
/// Pure range-selection logic, pulled out so it's testable without a live View: given the
/// on-screen id order, an anchor, and a target, returns the ids that should end up selected.
/// Falls back to just `targetId` if the anchor is nil or no longer present in `orderedIds`
/// (e.g. the very first Shift-click, or the anchor row was deleted/filtered out since).
nonisolated static func idsInRange(orderedIds: [UUID], anchorId: UUID?, targetId: UUID) -> Set<UUID> {
guard let anchorId,
let anchorIndex = orderedIds.firstIndex(of: anchorId),
let targetIndex = orderedIds.firstIndex(of: targetId)
else {
return [targetId]
}
let range = anchorIndex <= targetIndex ? anchorIndex...targetIndex : targetIndex...anchorIndex
return Set(orderedIds[range])
}
/// Selects every conversation between `lastClickedId` and `targetId` in on-screen order.
private func selectRange(to targetId: UUID) {
let orderedIds = visibleOrderedConversations.map { $0.id }
selectedConversations.formUnion(Self.idsInRange(orderedIds: orderedIds, anchorId: lastClickedId, targetId: targetId))
}
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
}
lastClickedId = nil
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()
}