// // SidebarView.swift // oAI // // Collapsible sidebar: new chat, conversation list, status pills // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . import SwiftUI #if os(macOS) import AppKit #endif struct SidebarView: View { @Environment(ChatViewModel.self) private var chatViewModel @State private var conversations: [Conversation] = [] @State private var searchText = "" private var filteredConversations: [Conversation] { guard !searchText.isEmpty else { return conversations } return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) } } var body: some View { VStack(spacing: 0) { // New Chat button Button(action: { chatViewModel.newConversation() }) { HStack(spacing: 8) { Image(systemName: "square.and.pencil") .font(.system(size: 14)) Text("New Chat") .font(.system(size: 14, weight: .medium)) Spacer() } .foregroundColor(.oaiPrimary) .padding(.horizontal, 12) .padding(.vertical, 10) .contentShape(Rectangle()) } .buttonStyle(.plain) // Search field HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.system(size: 12)) .foregroundStyle(.secondary) TextField("Search conversations…", text: $searchText) .textFieldStyle(.plain) .font(.system(size: 13)) if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .buttonStyle(.plain) } Divider().frame(height: 12) Button { chatViewModel.showConversations = true } label: { Image(systemName: "slider.horizontal.3") .font(.system(size: 11)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .help("Advanced search — semantic search, bulk delete, export") } .padding(7) .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) .padding(.horizontal, 8) .padding(.bottom, 6) Divider() // Conversation list if filteredConversations.isEmpty { Spacer() VStack(spacing: 8) { Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass") .font(.title2) .foregroundStyle(.tertiary) Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches") .font(.callout) .foregroundStyle(.secondary) } Spacer() } else { List { ForEach(filteredConversations) { conversation in SidebarConversationRow(conversation: conversation) .contentShape(Rectangle()) .onTapGesture { chatViewModel.loadConversation(conversation) } .listRowBackground( chatViewModel.currentConversationName == conversation.name ? Color.oaiAccent.opacity(0.15) : Color.clear ) .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) } } } .listStyle(.sidebar) } } .onAppear { loadConversations() } .onChange(of: chatViewModel.currentConversationName) { loadConversations() } .onChange(of: chatViewModel.messages.count) { loadConversations() } } private func loadConversations() { conversations = (try? DatabaseService.shared.listConversations()) ?? [] } private func deleteConversation(_ conversation: Conversation) { _ = try? DatabaseService.shared.deleteConversation(id: conversation.id) withAnimation { conversations.removeAll { $0.id == conversation.id } } } 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) if let i = conversations.firstIndex(where: { $0.id == conversation.id }) { conversations[i].name = newName conversations[i].updatedAt = Date() } chatViewModel.didRenameConversation(id: conversation.id, newName: newName) } catch { Log.db.error("Failed to rename conversation: \(error.localizedDescription)") } #endif } } // MARK: - Sidebar conversation row struct SidebarConversationRow: View { let conversation: Conversation private var formattedDate: String { let formatter = DateFormatter() formatter.dateFormat = "dd.MM.yyyy" return formatter.string(from: conversation.updatedAt) } var body: some View { VStack(alignment: .leading, spacing: 2) { Text(conversation.name) .font(.system(size: 13, weight: .medium)) .lineLimit(1) HStack(spacing: 4) { Text("^[\(conversation.messageCount) message](inflect: true)") .font(.system(size: 11)) Text("·") .font(.system(size: 11)) Text(formattedDate) .font(.system(size: 11)) } .foregroundStyle(.secondary) } .padding(.vertical, 2) } } #Preview { SidebarView() .environment(ChatViewModel()) .frame(width: 240, height: 600) }