UI redesign Phase 1: NavigationSplitView with collapsible sidebar
- Replace root VStack with NavigationSplitView (2-column, collapsible sidebar) - Add SidebarView: new chat button, conversation search, list with swipe actions - Slim HeaderView to text-only (provider + model + star); remove all icon rows - Move status pills (Online, MCP, Synced) to footer right side - Remove version number and shortcut hints from footer - Add resizable InputBar with drag handle (persisted height) and globe/network.slash online toggle - Fix Norwegian menu appearing on English systems (CFBundleLocalizations in Info.plist) - Add View menu (Model Info, History, Stats, Credits, Online Mode toggle ⌘⇧O) - Add ⌘L as alias for Search Conversations (muscle memory for /load users) - Add Check for Updates to Help menu with download URL from Gitea API - Add one-time Intel/Rosetta deprecation warning on first launch - Swift 6: fix self.Self.isoString() call sites in DatabaseService Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user