import SwiftUI struct ChatView: View { @Bindable var viewModel: ChatViewModel @FocusState private var isInputFocused: Bool var body: some View { VStack(spacing: 0) { // Messages list ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { ForEach(viewModel.messages) { message in MessageBubble(message: message) .id(message.id) } } .padding() } .onChange(of: viewModel.messages.count) { _, _ in if let lastMessage = viewModel.messages.last { withAnimation { proxy.scrollTo(lastMessage.id, anchor: .bottom) } } } .onChange(of: viewModel.messages.last?.content) { _, _ in if let lastMessage = viewModel.messages.last { withAnimation { proxy.scrollTo(lastMessage.id, anchor: .bottom) } } } } // Error message if let error = viewModel.errorMessage { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.yellow) Text(error) .font(.caption) .foregroundStyle(.secondary) Spacer() Button("Dismiss") { viewModel.errorMessage = nil } .buttonStyle(.plain) .font(.caption) } .padding(.horizontal) .padding(.vertical, 8) .background(.red.opacity(0.1)) } Divider() // Input area HStack(spacing: 12) { TextField("Message...", text: $viewModel.inputText, axis: .vertical) .textFieldStyle(.plain) .lineLimit(1...5) .focused($isInputFocused) .onSubmit { if !viewModel.inputText.isEmpty { viewModel.sendMessage() } } if viewModel.isLoading { Button { viewModel.stopGeneration() } label: { Image(systemName: "stop.circle.fill") .font(.title2) .foregroundStyle(.red) } .buttonStyle(.plain) } else { Button { viewModel.sendMessage() } label: { Image(systemName: "arrow.up.circle.fill") .font(.title2) .foregroundStyle(viewModel.inputText.isEmpty ? .gray : .accentColor) } .buttonStyle(.plain) .disabled(viewModel.inputText.isEmpty) } } .padding() } .frame(minWidth: 400, minHeight: 500) .toolbar { ToolbarItem(placement: .primaryAction) { Button { viewModel.clearChat() } label: { Image(systemName: "trash") } .help("Clear chat") .disabled(viewModel.messages.isEmpty) } } .task { await viewModel.initialize() } .onAppear { // Force the app to become active and accept keyboard input NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { // Make sure the window is key if let window = NSApp.windows.first(where: { $0.title == "Chat" }) { window.makeKeyAndOrderFront(nil) } isInputFocused = true } } .onDisappear { // Return to accessory mode when chat is closed if NSApp.windows.filter({ $0.isVisible && $0.title != "" }).isEmpty { NSApp.setActivationPolicy(.accessory) } } } } struct MessageBubble: View { let message: ChatMessage var body: some View { HStack { if message.role == .user { Spacer(minLength: 60) } VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) { Text(message.content) .textSelection(.enabled) .padding(.horizontal, 12) .padding(.vertical, 8) .background(bubbleColor) .foregroundStyle(message.role == .user ? .white : .primary) .clipShape(RoundedRectangle(cornerRadius: 16)) if message.isStreaming { HStack(spacing: 4) { ProgressView() .scaleEffect(0.6) Text("Generating...") .font(.caption2) .foregroundStyle(.secondary) } } } if message.role == .assistant { Spacer(minLength: 60) } } } private var bubbleColor: Color { switch message.role { case .user: return .accentColor case .assistant: return Color(nsColor: .controlBackgroundColor) } } }