Added a lot of functionality. Bugfixes and changes
This commit is contained in:
@@ -36,6 +36,12 @@ struct ChatView: View {
|
||||
.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)
|
||||
@@ -57,6 +63,40 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-continue countdown banner
|
||||
if viewModel.isAutoContinuing {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(0.7)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(ThinkingVerbs.random())
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(.oaiPrimary)
|
||||
Text("Continuing in \(viewModel.autoContinueCountdown)s")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
viewModel.cancelAutoContinue()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.tint(.red)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.stroke(Color.blue.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// Input bar
|
||||
InputBar(
|
||||
text: $viewModel.inputText,
|
||||
@@ -74,6 +114,41 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -41,9 +41,14 @@ struct ContentView: View {
|
||||
models: chatViewModel.availableModels,
|
||||
selectedModel: chatViewModel.selectedModel,
|
||||
onSelect: { model in
|
||||
let oldModel = chatViewModel.selectedModel
|
||||
chatViewModel.selectedModel = model
|
||||
SettingsService.shared.defaultModel = model.id
|
||||
chatViewModel.showModelSelector = false
|
||||
// Trigger auto-save on model switch
|
||||
Task {
|
||||
await chatViewModel.onModelSwitch(from: oldModel, to: model)
|
||||
}
|
||||
}
|
||||
)
|
||||
.task {
|
||||
@@ -88,22 +93,26 @@ struct ContentView: View {
|
||||
#if os(macOS)
|
||||
@ToolbarContentBuilder
|
||||
private var macOSToolbar: some ToolbarContent {
|
||||
let settings = SettingsService.shared
|
||||
let showLabels = settings.showToolbarLabels
|
||||
let scale = iconScale(for: settings.toolbarIconSize)
|
||||
|
||||
ToolbarItemGroup(placement: .automatic) {
|
||||
// New conversation
|
||||
Button(action: { chatViewModel.newConversation() }) {
|
||||
Label("New Chat", systemImage: "square.and.pencil")
|
||||
ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, scale: scale)
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
.help("New conversation")
|
||||
|
||||
Button(action: { chatViewModel.showConversations = true }) {
|
||||
Label("Conversations", systemImage: "clock.arrow.circlepath")
|
||||
ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, scale: scale)
|
||||
}
|
||||
.keyboardShortcut("l", modifiers: .command)
|
||||
.help("Saved conversations (Cmd+L)")
|
||||
|
||||
Button(action: { chatViewModel.showHistory = true }) {
|
||||
Label("History", systemImage: "list.bullet")
|
||||
ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, scale: scale)
|
||||
}
|
||||
.keyboardShortcut("h", modifiers: .command)
|
||||
.help("Command history (Cmd+H)")
|
||||
@@ -111,7 +120,7 @@ struct ContentView: View {
|
||||
Spacer()
|
||||
|
||||
Button(action: { chatViewModel.showModelSelector = true }) {
|
||||
Label("Model", systemImage: "cpu")
|
||||
ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, scale: scale)
|
||||
}
|
||||
.keyboardShortcut("m", modifiers: .command)
|
||||
.help("Select AI model (Cmd+M)")
|
||||
@@ -121,39 +130,68 @@ struct ContentView: View {
|
||||
chatViewModel.modelInfoTarget = model
|
||||
}
|
||||
}) {
|
||||
Label("Model Info", systemImage: "info.circle")
|
||||
ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, scale: scale)
|
||||
}
|
||||
.keyboardShortcut("i", modifiers: .command)
|
||||
.help("Model info (Cmd+I)")
|
||||
.disabled(chatViewModel.selectedModel == nil)
|
||||
|
||||
Button(action: { chatViewModel.showStats = true }) {
|
||||
Label("Stats", systemImage: "chart.bar")
|
||||
ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, scale: scale)
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
.help("Session statistics (Cmd+S)")
|
||||
|
||||
Button(action: { chatViewModel.showCredits = true }) {
|
||||
Label("Credits", systemImage: "creditcard")
|
||||
ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, scale: scale)
|
||||
}
|
||||
.help("Check API credits")
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { chatViewModel.showSettings = true }) {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, scale: scale)
|
||||
}
|
||||
.keyboardShortcut(",", modifiers: .command)
|
||||
.help("Settings (Cmd+,)")
|
||||
|
||||
Button(action: { chatViewModel.showHelp = true }) {
|
||||
Label("Help", systemImage: "questionmark.circle")
|
||||
ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, scale: scale)
|
||||
}
|
||||
.keyboardShortcut("/", modifiers: .command)
|
||||
.help("Help & commands (Cmd+/)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Helper function to convert icon size to imageScale
|
||||
private func iconScale(for size: Double) -> Image.Scale {
|
||||
switch size {
|
||||
case ...18: return .small
|
||||
case 19...24: return .medium
|
||||
default: return .large
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper view for toolbar labels
|
||||
struct ToolbarLabel: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let showLabels: Bool
|
||||
let scale: Image.Scale
|
||||
|
||||
var body: some View {
|
||||
if showLabels {
|
||||
Label(title, systemImage: systemImage)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.imageScale(scale)
|
||||
} else {
|
||||
Label(title, systemImage: systemImage)
|
||||
.labelStyle(.iconOnly)
|
||||
.imageScale(scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -19,22 +19,27 @@ struct FooterView: View {
|
||||
label: "Messages",
|
||||
value: "\(stats.messageCount)"
|
||||
)
|
||||
|
||||
|
||||
FooterItem(
|
||||
icon: "chart.bar.xaxis",
|
||||
label: "Tokens",
|
||||
value: "\(stats.totalTokens) (\(stats.totalInputTokens) in, \(stats.totalOutputTokens) out)"
|
||||
)
|
||||
|
||||
|
||||
FooterItem(
|
||||
icon: "dollarsign.circle",
|
||||
label: "Cost",
|
||||
value: stats.totalCostDisplay
|
||||
)
|
||||
|
||||
// Git sync status (if enabled)
|
||||
if SettingsService.shared.syncEnabled && SettingsService.shared.syncAutoSave {
|
||||
SyncStatusFooter()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
// Shortcuts hint
|
||||
#if os(macOS)
|
||||
Text("⌘M Model • ⌘K Clear • ⌘S Stats")
|
||||
@@ -77,6 +82,72 @@ struct FooterItem: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SyncStatusFooter: View {
|
||||
private let gitSync = GitSyncService.shared
|
||||
private let guiSize = SettingsService.shared.guiTextSize
|
||||
@State private var syncText = "Not Synced"
|
||||
@State private var syncColor: Color = .secondary
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.system(size: guiSize - 2))
|
||||
.foregroundColor(syncColor)
|
||||
|
||||
Text(syncText)
|
||||
.font(.system(size: guiSize - 2, weight: .medium))
|
||||
.foregroundColor(syncColor)
|
||||
}
|
||||
.onAppear {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.syncStatus.lastSyncTime) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.lastSyncError) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
.onChange(of: gitSync.isSyncing) {
|
||||
updateSyncStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSyncStatus() {
|
||||
if let error = gitSync.lastSyncError {
|
||||
syncText = "Error With Sync"
|
||||
syncColor = .red
|
||||
} else if gitSync.isSyncing {
|
||||
syncText = "Syncing..."
|
||||
syncColor = .orange
|
||||
} else if let lastSync = gitSync.syncStatus.lastSyncTime {
|
||||
syncText = "Last Sync: \(timeAgo(lastSync))"
|
||||
syncColor = .green
|
||||
} else if gitSync.syncStatus.isCloned {
|
||||
syncText = "Not Synced"
|
||||
syncColor = .secondary
|
||||
} else {
|
||||
syncText = "Not Configured"
|
||||
syncColor = .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let seconds = Int(Date().timeIntervalSince(date))
|
||||
if seconds < 60 {
|
||||
return "just now"
|
||||
} else if seconds < 3600 {
|
||||
let minutes = seconds / 60
|
||||
return "\(minutes)m ago"
|
||||
} else if seconds < 86400 {
|
||||
let hours = seconds / 3600
|
||||
return "\(hours)h ago"
|
||||
} else {
|
||||
let days = seconds / 86400
|
||||
return "\(days)d ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
@@ -18,6 +18,7 @@ struct HeaderView: View {
|
||||
let onProviderChange: (Settings.Provider) -> Void
|
||||
private let settings = SettingsService.shared
|
||||
private let registry = ProviderRegistry.shared
|
||||
private let gitSync = GitSyncService.shared
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
@@ -120,10 +121,13 @@ struct HeaderView: View {
|
||||
if mcpEnabled {
|
||||
StatusPill(icon: "folder", label: "MCP", color: .blue)
|
||||
}
|
||||
if settings.syncEnabled && settings.syncAutoSave {
|
||||
SyncStatusPill()
|
||||
}
|
||||
}
|
||||
|
||||
// Divider between status and stats
|
||||
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true {
|
||||
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) {
|
||||
Divider()
|
||||
.frame(height: 16)
|
||||
.opacity(0.5)
|
||||
@@ -186,6 +190,81 @@ struct StatusPill: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SyncStatusPill: View {
|
||||
private let gitSync = GitSyncService.shared
|
||||
@State private var syncColor: Color = .secondary
|
||||
@State private var syncLabel: String = "Sync"
|
||||
@State private var tooltipText: String = ""
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
Circle()
|
||||
.fill(syncColor)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(syncLabel)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.oaiSecondary)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(syncColor.opacity(0.1), in: Capsule())
|
||||
.help(tooltipText)
|
||||
.onAppear {
|
||||
updateState()
|
||||
}
|
||||
.onChange(of: gitSync.syncStatus) {
|
||||
updateState()
|
||||
}
|
||||
.onChange(of: gitSync.isSyncing) {
|
||||
updateState()
|
||||
}
|
||||
.onChange(of: gitSync.lastSyncError) {
|
||||
updateState()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateState() {
|
||||
// Determine sync state
|
||||
if let error = gitSync.lastSyncError {
|
||||
syncColor = .red
|
||||
syncLabel = "Error"
|
||||
tooltipText = "Sync failed: \(error)"
|
||||
} else if gitSync.isSyncing {
|
||||
syncColor = .orange
|
||||
syncLabel = "Syncing"
|
||||
tooltipText = "Syncing..."
|
||||
} else if gitSync.syncStatus.isCloned {
|
||||
syncColor = .green
|
||||
syncLabel = "Synced"
|
||||
if let lastSync = gitSync.syncStatus.lastSyncTime {
|
||||
tooltipText = "Last synced: \(timeAgo(lastSync))"
|
||||
} else {
|
||||
tooltipText = "Synced"
|
||||
}
|
||||
} else {
|
||||
syncColor = .secondary
|
||||
syncLabel = "Sync"
|
||||
tooltipText = "Sync not configured"
|
||||
}
|
||||
}
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let seconds = Int(Date().timeIntervalSince(date))
|
||||
if seconds < 60 {
|
||||
return "just now"
|
||||
} else if seconds < 3600 {
|
||||
let minutes = seconds / 60
|
||||
return "\(minutes)m ago"
|
||||
} else if seconds < 86400 {
|
||||
let hours = seconds / 3600
|
||||
return "\(hours)h ago"
|
||||
} else {
|
||||
let days = seconds / 86400
|
||||
return "\(days)d ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
HeaderView(
|
||||
|
||||
@@ -34,15 +34,20 @@ struct InputBar: View {
|
||||
VStack(spacing: 0) {
|
||||
// Command dropdown (if showing)
|
||||
if showCommandDropdown && text.hasPrefix("/") {
|
||||
CommandSuggestionsView(
|
||||
searchText: text,
|
||||
selectedIndex: selectedSuggestionIndex,
|
||||
onSelect: { command in
|
||||
selectCommand(command)
|
||||
}
|
||||
)
|
||||
.frame(maxHeight: 200)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
HStack {
|
||||
CommandSuggestionsView(
|
||||
searchText: text,
|
||||
selectedIndex: selectedSuggestionIndex,
|
||||
onSelect: { command in
|
||||
selectCommand(command)
|
||||
}
|
||||
)
|
||||
.frame(width: 400)
|
||||
.frame(maxHeight: 200)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, 96) // Align with input box (status badges + spacing)
|
||||
}
|
||||
|
||||
// Input area
|
||||
@@ -127,13 +132,13 @@ struct InputBar: View {
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
// Plain Return on single line: send
|
||||
if !text.contains("\n") && !text.isEmpty {
|
||||
// Return (plain or with Cmd): send message
|
||||
if !text.isEmpty {
|
||||
onSend()
|
||||
return .handled
|
||||
}
|
||||
// Otherwise: let system handle (insert newline)
|
||||
return .ignored
|
||||
// Empty text: do nothing
|
||||
return .handled
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -323,7 +328,6 @@ struct CommandSuggestionsView: View {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.oaiBorder, lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
169
oAI/Views/Main/SyncStatusIndicator.swift
Normal file
169
oAI/Views/Main/SyncStatusIndicator.swift
Normal file
@@ -0,0 +1,169 @@
|
||||
//
|
||||
// SyncStatusIndicator.swift
|
||||
// oAI
|
||||
//
|
||||
// Git sync status indicator (bottom-right corner)
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum SyncState {
|
||||
case disabled // Gray - sync not configured or disabled
|
||||
case synced // Green - successfully synced
|
||||
case syncing // Yellow - sync in progress
|
||||
case error(String) // Red - sync failed with error message
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .disabled: return .secondary
|
||||
case .synced: return .green
|
||||
case .syncing: return .orange
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .disabled: return "arrow.triangle.2.circlepath"
|
||||
case .synced: return "checkmark.circle.fill"
|
||||
case .syncing: return "arrow.triangle.2.circlepath"
|
||||
case .error: return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var tooltipText: String {
|
||||
switch self {
|
||||
case .disabled:
|
||||
return "Auto-sync disabled"
|
||||
case .synced:
|
||||
if let lastSync = GitSyncService.shared.syncStatus.lastSyncTime {
|
||||
return "Last synced: \(timeAgo(lastSync))"
|
||||
} else {
|
||||
return "Synced"
|
||||
}
|
||||
case .syncing:
|
||||
return "Syncing..."
|
||||
case .error(let message):
|
||||
return "Sync failed: \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let seconds = Int(Date().timeIntervalSince(date))
|
||||
if seconds < 60 {
|
||||
return "just now"
|
||||
} else if seconds < 3600 {
|
||||
let minutes = seconds / 60
|
||||
return "\(minutes)m ago"
|
||||
} else if seconds < 86400 {
|
||||
let hours = seconds / 3600
|
||||
return "\(hours)h ago"
|
||||
} else {
|
||||
let days = seconds / 86400
|
||||
return "\(days)d ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SyncStatusIndicator: View {
|
||||
@State private var isHovering = false
|
||||
@State private var syncState: SyncState = .disabled
|
||||
@State private var showSettings = false
|
||||
|
||||
private let settings = SettingsService.shared
|
||||
private let gitSync = GitSyncService.shared
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
// Floating indicator
|
||||
ZStack {
|
||||
// Background circle
|
||||
Circle()
|
||||
.fill(Color(nsColor: .windowBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2)
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
// Icon
|
||||
statusIcon
|
||||
}
|
||||
.scaleEffect(isHovering ? 1.1 : 1.0)
|
||||
.animation(.spring(response: 0.3), value: isHovering)
|
||||
.onHover { hovering in
|
||||
isHovering = hovering
|
||||
}
|
||||
.help(syncState.tooltipText)
|
||||
.onTapGesture {
|
||||
if case .error = syncState {
|
||||
// Open settings on error
|
||||
showSettings = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateState()
|
||||
}
|
||||
.onChange(of: gitSync.syncStatus) {
|
||||
updateState()
|
||||
}
|
||||
.onChange(of: gitSync.isSyncing) {
|
||||
updateState()
|
||||
}
|
||||
.onChange(of: gitSync.lastSyncError) {
|
||||
updateState()
|
||||
}
|
||||
.onChange(of: settings.syncEnabled) {
|
||||
updateState()
|
||||
}
|
||||
.onChange(of: settings.syncAutoSave) {
|
||||
updateState()
|
||||
}
|
||||
}
|
||||
|
||||
private var statusIcon: some View {
|
||||
Image(systemName: syncState.icon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(syncState.color)
|
||||
}
|
||||
|
||||
private func updateState() {
|
||||
// Determine current sync state
|
||||
guard settings.syncEnabled && settings.syncConfigured else {
|
||||
syncState = .disabled
|
||||
return
|
||||
}
|
||||
|
||||
guard gitSync.syncStatus.isCloned else {
|
||||
syncState = .disabled
|
||||
return
|
||||
}
|
||||
|
||||
// Check for error
|
||||
if let error = gitSync.lastSyncError {
|
||||
syncState = .error(error)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if currently syncing
|
||||
if gitSync.isSyncing {
|
||||
syncState = .syncing
|
||||
return
|
||||
}
|
||||
|
||||
// All good
|
||||
syncState = .synced
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SyncStatusIndicator()
|
||||
}
|
||||
331
oAI/Views/Screens/EmailLogView.swift
Normal file
331
oAI/Views/Screens/EmailLogView.swift
Normal file
@@ -0,0 +1,331 @@
|
||||
//
|
||||
// EmailLogView.swift
|
||||
// oAI
|
||||
//
|
||||
// Email handler activity log viewer
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct EmailLogView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Bindable private var settings = SettingsService.shared
|
||||
private let emailLogService = EmailLogService.shared
|
||||
|
||||
@State private var logs: [EmailLog] = []
|
||||
@State private var searchText = ""
|
||||
@State private var showClearConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Title and controls
|
||||
header
|
||||
|
||||
Divider()
|
||||
|
||||
// Statistics
|
||||
statistics
|
||||
|
||||
Divider()
|
||||
|
||||
// Search bar
|
||||
searchBar
|
||||
|
||||
// Logs list
|
||||
logsList
|
||||
|
||||
Divider()
|
||||
|
||||
// Bottom actions
|
||||
bottomActions
|
||||
}
|
||||
.frame(width: 800, height: 600)
|
||||
.onAppear {
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Text("Email Activity Log")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
private var statistics: some View {
|
||||
let stats = emailLogService.getStatistics()
|
||||
|
||||
return HStack(spacing: 30) {
|
||||
EmailStatItem(title: "Total", value: "\(stats.total)", color: .blue)
|
||||
EmailStatItem(title: "Successful", value: "\(stats.successful)", color: .green)
|
||||
EmailStatItem(title: "Errors", value: "\(stats.errors)", color: .red)
|
||||
|
||||
if stats.total > 0 {
|
||||
EmailStatItem(
|
||||
title: "Success Rate",
|
||||
value: String(format: "%.1f%%", stats.successRate * 100),
|
||||
color: .green
|
||||
)
|
||||
}
|
||||
|
||||
if let avgTime = stats.averageResponseTime {
|
||||
EmailStatItem(
|
||||
title: "Avg Response Time",
|
||||
value: String(format: "%.1fs", avgTime),
|
||||
color: .orange
|
||||
)
|
||||
}
|
||||
|
||||
if stats.totalCost > 0 {
|
||||
EmailStatItem(
|
||||
title: "Total Cost",
|
||||
value: String(format: "$%.4f", stats.totalCost),
|
||||
color: .purple
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Search Bar
|
||||
|
||||
private var searchBar: some View {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("Search by sender, subject, or content...", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
.onChange(of: searchText) {
|
||||
filterLogs()
|
||||
}
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button(action: {
|
||||
searchText = ""
|
||||
loadLogs()
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Logs List
|
||||
|
||||
private var logsList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 8) {
|
||||
if logs.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ForEach(logs) { log in
|
||||
EmailLogRow(log: log)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("No email activity yet")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if !settings.emailHandlerEnabled {
|
||||
Text("Enable email handler in Settings to start monitoring emails")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Bottom Actions
|
||||
|
||||
private var bottomActions: some View {
|
||||
HStack {
|
||||
Text("\(logs.count) \(logs.count == 1 ? "entry" : "entries")")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
showClearConfirmation = true
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "trash")
|
||||
Text("Clear All")
|
||||
}
|
||||
}
|
||||
.disabled(logs.isEmpty)
|
||||
.alert("Clear Email Log?", isPresented: $showClearConfirmation) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Clear All", role: .destructive) {
|
||||
emailLogService.clearAllLogs()
|
||||
loadLogs()
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete all email activity logs. This action cannot be undone.")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Data Operations
|
||||
|
||||
private func loadLogs() {
|
||||
logs = emailLogService.loadLogs(limit: 500)
|
||||
}
|
||||
|
||||
private func filterLogs() {
|
||||
if searchText.isEmpty {
|
||||
loadLogs()
|
||||
} else {
|
||||
let allLogs = emailLogService.loadLogs(limit: 500)
|
||||
logs = allLogs.filter { log in
|
||||
log.sender.localizedCaseInsensitiveContains(searchText) ||
|
||||
log.subject.localizedCaseInsensitiveContains(searchText) ||
|
||||
log.emailContent.localizedCaseInsensitiveContains(searchText) ||
|
||||
(log.aiResponse?.localizedCaseInsensitiveContains(searchText) ?? false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Email Log Row
|
||||
|
||||
struct EmailLogRow: View {
|
||||
let log: EmailLog
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Header: status, sender, time
|
||||
HStack {
|
||||
Image(systemName: log.status.iconName)
|
||||
.foregroundColor(statusColor)
|
||||
|
||||
Text(log.sender)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(log.timestamp.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// Subject
|
||||
Text(log.subject)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
// Content preview
|
||||
if log.status == .success, let response = log.aiResponse {
|
||||
Text(response)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if log.status == .error, let error = log.errorMessage {
|
||||
Text("Error: \(error)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.red)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
// Metadata
|
||||
HStack(spacing: 12) {
|
||||
if let model = log.modelId {
|
||||
Label(model, systemImage: "cpu")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let tokens = log.tokens {
|
||||
Label("\(tokens) tokens", systemImage: "number")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let cost = log.cost, cost > 0 {
|
||||
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let responseTime = log.responseTime {
|
||||
Label(String(format: "%.1fs", responseTime), systemImage: "clock")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(statusColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch log.status {
|
||||
case .success: return .green
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stat Item
|
||||
|
||||
struct EmailStatItem: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(value)
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundColor(color)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
EmailLogView()
|
||||
}
|
||||
@@ -12,6 +12,8 @@ struct HistoryView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var historyEntries: [HistoryEntry] = []
|
||||
@State private var selectedIndex: Int = 0
|
||||
@FocusState private var isListFocused: Bool
|
||||
var onSelect: ((String) -> Void)?
|
||||
|
||||
private var filteredHistory: [HistoryEntry] {
|
||||
@@ -80,23 +82,61 @@ struct HistoryView: View {
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
List {
|
||||
ForEach(filteredHistory) { entry in
|
||||
HistoryRow(entry: entry)
|
||||
ScrollViewReader { proxy in
|
||||
List {
|
||||
ForEach(Array(filteredHistory.enumerated()), id: \.element.id) { index, entry in
|
||||
HistoryRow(
|
||||
entry: entry,
|
||||
isSelected: index == selectedIndex
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedIndex = index
|
||||
onSelect?(entry.input)
|
||||
dismiss()
|
||||
}
|
||||
.id(entry.id)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.focused($isListFocused)
|
||||
.onChange(of: selectedIndex) {
|
||||
withAnimation {
|
||||
proxy.scrollTo(filteredHistory[selectedIndex].id, anchor: .center)
|
||||
}
|
||||
}
|
||||
.onChange(of: searchText) {
|
||||
selectedIndex = 0
|
||||
}
|
||||
#if os(macOS)
|
||||
.onKeyPress(.upArrow) {
|
||||
if selectedIndex > 0 {
|
||||
selectedIndex -= 1
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.downArrow) {
|
||||
if selectedIndex < filteredHistory.count - 1 {
|
||||
selectedIndex += 1
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.return) {
|
||||
if !filteredHistory.isEmpty && selectedIndex < filteredHistory.count {
|
||||
onSelect?(filteredHistory[selectedIndex].input)
|
||||
dismiss()
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 400)
|
||||
.background(Color.oaiBackground)
|
||||
.task {
|
||||
loadHistory()
|
||||
isListFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +152,7 @@ struct HistoryView: View {
|
||||
|
||||
struct HistoryRow: View {
|
||||
let entry: HistoryEntry
|
||||
let isSelected: Bool
|
||||
private let settings = SettingsService.shared
|
||||
|
||||
var body: some View {
|
||||
@@ -134,6 +175,7 @@ struct HistoryRow: View {
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 4)
|
||||
.listRowBackground(isSelected ? Color.oaiAccent.opacity(0.2) : Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,3 +184,13 @@ struct HistoryRow: View {
|
||||
print("Selected: \(input)")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("History Row") {
|
||||
HistoryRow(
|
||||
entry: HistoryEntry(
|
||||
input: "Tell me about Swift concurrency",
|
||||
timestamp: Date()
|
||||
),
|
||||
isSelected: true
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user