swift-apple-intelligence-grpc/Sources/AppleIntelligenceApp/Views/ChatView.swift
Mathias Beaulieu-Duncan e0bf17da3d Add macOS menu bar app with chat and settings
- Restructure project into three targets:
  - AppleIntelligenceCore: Shared gRPC service code
  - AppleIntelligenceServer: CLI server
  - AppleIntelligenceApp: Menu bar app

- Menu bar app features:
  - Toggle server on/off from menu bar
  - Chat window with streaming AI responses
  - Settings: host, port, API key, auto-start, launch at login
  - Proper window focus handling for menu bar apps

- Add build scripts for distribution:
  - build-app.sh: Creates signed .app bundle
  - create-dmg.sh: Creates distributable DMG

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 04:31:31 -05:00

174 lines
5.8 KiB
Swift

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)
}
}
}