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.
197 lines
7.0 KiB
Swift
197 lines
7.0 KiB
Swift
//
|
|
// ChatView.swift
|
|
// Confab
|
|
//
|
|
// Main chat interface
|
|
//
|
|
// 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 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,
|
|
conversationName: viewModel.currentConversationName,
|
|
hasUnsavedChanges: viewModel.hasUnsavedChanges,
|
|
onQuickSave: viewModel.quickSave
|
|
)
|
|
|
|
// 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.confabBackground)
|
|
.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.confabBackground)
|
|
.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() }
|
|
)
|
|
}
|
|
.sheet(item: Binding(
|
|
get: { MCPService.shared.pendingPersonalDataAction },
|
|
set: { _ in }
|
|
)) { pending in
|
|
PersonalDataApprovalSheet(
|
|
pending: pending,
|
|
onApprove: { forSession in MCPService.shared.approvePendingPersonalDataAction(forSession: forSession) },
|
|
onDeny: { MCPService.shared.denyPendingPersonalDataAction() }
|
|
)
|
|
}
|
|
.sheet(item: Binding(
|
|
get: { GitSyncService.shared.pendingGitConflict },
|
|
set: { _ in }
|
|
)) { pending in
|
|
GitSyncConflictSheet(
|
|
pending: pending,
|
|
onFixForMe: { await GitSyncService.shared.autoResolveUntrackedConflict(pending) },
|
|
onFixMyself: { GitSyncService.shared.showManualFixInstructions(for: pending) },
|
|
onDismiss: { GitSyncService.shared.dismissPendingGitConflict() }
|
|
)
|
|
}
|
|
.sheet(item: Binding(
|
|
get: { GitSyncService.shared.pendingManualFixInstructions },
|
|
set: { _ in }
|
|
)) { pending in
|
|
GitSyncManualFixSheet(
|
|
files: pending.files,
|
|
syncPath: SettingsService.shared.syncLocalPath,
|
|
onDone: { GitSyncService.shared.dismissManualFixInstructions() }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
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(.confabSecondary)
|
|
|
|
HStack(spacing: 4) {
|
|
ForEach(0..<3) { index in
|
|
Circle()
|
|
.fill(Color.confabSecondary)
|
|
.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.confabSecondary.opacity(0.05))
|
|
.cornerRadius(8)
|
|
.onAppear {
|
|
animating = true
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ChatView(onModelSelect: {}, onProviderChange: { _ in })
|
|
.environment(ChatViewModel())
|
|
}
|