Files
oai-swift/oAI/Views/Main/ChatView.swift
T
rune 8451db1142 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>
2026-06-16 11:18:48 +02:00

165 lines
5.5 KiB
Swift

//
// ChatView.swift
// oAI
//
// Main chat interface
//
// 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
struct ChatView: View {
@Environment(ChatViewModel.self) var viewModel
let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void
var body: some View {
@Bindable var viewModel = viewModel
VStack(spacing: 0) {
// Header
HeaderView(
provider: viewModel.currentProvider,
model: viewModel.selectedModel,
onModelSelect: onModelSelect,
onProviderChange: onProviderChange
)
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(viewModel.messages) { message in
MessageRow(message: message, viewModel: viewModel)
.id(message.id)
}
// Processing indicator
if viewModel.isGenerating && viewModel.messages.last?.isStreaming != true {
ProcessingIndicator()
.padding(.horizontal)
}
// Invisible bottom anchor for auto-scroll
Color.clear
.frame(height: 1)
.id("bottom")
}
.padding()
}
.background(Color.oaiBackground)
.onChange(of: viewModel.messages.count) {
withAnimation {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.onChange(of: viewModel.messages.last?.content) {
// Auto-scroll as streaming content arrives
if viewModel.isGenerating {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
// Input bar
InputBar(
text: $viewModel.inputText,
isGenerating: viewModel.isGenerating,
onlineMode: viewModel.onlineMode,
onSend: viewModel.sendMessage,
onCancel: viewModel.cancelGeneration,
onToggleOnline: {
viewModel.onlineMode.toggle()
SettingsService.shared.onlineMode = viewModel.onlineMode
}
)
// Footer
FooterView(
stats: viewModel.sessionStats,
conversationName: viewModel.currentConversationName,
hasUnsavedChanges: viewModel.hasUnsavedChanges,
onQuickSave: viewModel.quickSave,
onlineMode: viewModel.onlineMode,
mcpEnabled: viewModel.mcpEnabled
)
}
.background(Color.oaiBackground)
.sheet(isPresented: $viewModel.showShortcuts) {
ShortcutsView()
}
.sheet(isPresented: $viewModel.showSkills) {
AgentSkillsView()
}
.sheet(isPresented: $viewModel.showJarvis) {
JarvisView()
}
.sheet(item: Binding(
get: { MCPService.shared.pendingBashCommand },
set: { _ in }
)) { pending in
BashApprovalSheet(
pending: pending,
onApprove: { forSession in MCPService.shared.approvePendingBashCommand(forSession: forSession) },
onDeny: { MCPService.shared.denyPendingBashCommand() }
)
}
}
}
struct ProcessingIndicator: View {
@State private var animating = false
@State private var thinkingText = ThinkingVerbs.random()
var body: some View {
HStack(spacing: 8) {
Text(thinkingText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.oaiSecondary)
HStack(spacing: 4) {
ForEach(0..<3) { index in
Circle()
.fill(Color.oaiSecondary)
.frame(width: 6, height: 6)
.scaleEffect(animating ? 1.0 : 0.5)
.animation(
.easeInOut(duration: 0.6)
.repeatForever()
.delay(Double(index) * 0.2),
value: animating
)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.oaiSecondary.opacity(0.05))
.cornerRadius(8)
.onAppear {
animating = true
}
}
}
#Preview {
ChatView(onModelSelect: {}, onProviderChange: { _ in })
.environment(ChatViewModel())
}