Files
oai-swift/oAI/Views/Screens/ConversationListView.swift
T
rune c3abc5a748 Update remaining oai.pm references to confab.no
Covers the per-file license-header comment (~80 Swift files) plus
the contact/website links in README.md, PRIVACY.md, and SECURITY.md.
2026-08-03 08:44:35 +02:00

925 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)
}
}
} 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) -> 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")
}
}
.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()
}