From 2f4d0e3e9e0467eb80ccdf0a2fba3e1d72e16e58 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Mon, 29 Dec 2025 23:46:17 -0800 Subject: [PATCH] feat(conversation-assistant): add activity log UI for real-time action visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows sync operations in the menu bar popup as they happen, with color-coded icons (info/success/warning/error) and relative timestamps. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../macos/Sources/Models/ActivityLog.swift | 75 +++++++++++++++++ .../macos/Sources/Services/SyncManager.swift | 29 +++++++ .../macos/Sources/Views/MenuBarView.swift | 82 +++++++++++++++++++ .../Sources/Views/MenuBarViewModel.swift | 10 +++ 4 files changed, 196 insertions(+) create mode 100644 features/conversation-assistant/macos/Sources/Models/ActivityLog.swift diff --git a/features/conversation-assistant/macos/Sources/Models/ActivityLog.swift b/features/conversation-assistant/macos/Sources/Models/ActivityLog.swift new file mode 100644 index 000000000..771cf3ac0 --- /dev/null +++ b/features/conversation-assistant/macos/Sources/Models/ActivityLog.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Represents a single activity log entry +struct LogEntry: Identifiable, Equatable { + let id = UUID() + let timestamp: Date + let message: String + let level: LogLevel + + enum LogLevel { + case info + case success + case warning + case error + + var icon: String { + switch self { + case .info: return "info.circle" + case .success: return "checkmark.circle" + case .warning: return "exclamationmark.triangle" + case .error: return "xmark.circle" + } + } + } + + static func == (lhs: LogEntry, rhs: LogEntry) -> Bool { + lhs.id == rhs.id + } +} + +/// Manages activity log entries with a maximum capacity +@MainActor +class ActivityLog: ObservableObject { + static let shared = ActivityLog() + + @Published private(set) var entries: [LogEntry] = [] + + /// Maximum number of entries to keep + private let maxEntries = 50 + + private init() {} + + func log(_ message: String, level: LogEntry.LogLevel = .info) { + let entry = LogEntry(timestamp: Date(), message: message, level: level) + entries.insert(entry, at: 0) + + // Trim to max entries + if entries.count > maxEntries { + entries = Array(entries.prefix(maxEntries)) + } + + // Also log to system console + NSLog("ActivityLog: [\(level)] \(message)") + } + + func info(_ message: String) { + log(message, level: .info) + } + + func success(_ message: String) { + log(message, level: .success) + } + + func warning(_ message: String) { + log(message, level: .warning) + } + + func error(_ message: String) { + log(message, level: .error) + } + + func clear() { + entries.removeAll() + } +} diff --git a/features/conversation-assistant/macos/Sources/Services/SyncManager.swift b/features/conversation-assistant/macos/Sources/Services/SyncManager.swift index 57afaad8e..87c8b00ec 100644 --- a/features/conversation-assistant/macos/Sources/Services/SyncManager.swift +++ b/features/conversation-assistant/macos/Sources/Services/SyncManager.swift @@ -40,6 +40,7 @@ class SyncManager: ObservableObject { private let imessageReader = iMessageReader.shared private let apiClient = APIClient.shared + private let activityLog = ActivityLog.shared private var syncTimer: Timer? private init() { @@ -75,22 +76,27 @@ class SyncManager: ObservableObject { func startSync() { NSLog("SyncManager: startSync called") + activityLog.info("Starting sync service...") syncError = .none // Connect to iMessage database do { try imessageReader.connect() NSLog("SyncManager: Connected to iMessage database") + activityLog.success("Connected to iMessage database") } catch { NSLog("SyncManager: Failed to connect to iMessage: \(error.localizedDescription)") // Detect permission errors (SQLite error 23 = authorization denied) let errorMsg = error.localizedDescription.lowercased() if errorMsg.contains("authorization denied") || errorMsg.contains("error 23") { syncError = .fullDiskAccessRequired + activityLog.error("Full Disk Access required") } else if errorMsg.contains("not found") { syncError = .databaseNotFound + activityLog.error("iMessage database not found") } else { syncError = .connectionFailed(error.localizedDescription) + activityLog.error("Connection failed: \(error.localizedDescription)") } return } @@ -101,8 +107,10 @@ class SyncManager: ObservableObject { let didReset = await checkSchemaVersionAndReset() if didReset { NSLog("SyncManager: Schema version reset completed, continuing with normal startup") + activityLog.info("Schema version updated, resync required") } + activityLog.info("Loading contacts...") let contactsLoaded = await imessageReader.loadContacts() NSLog("SyncManager: Contacts loaded: \(contactsLoaded)") @@ -124,6 +132,7 @@ class SyncManager: ObservableObject { self?.syncNow() } } + activityLog.info("Scheduled automatic sync every 30s") } func stopSync() { @@ -151,11 +160,13 @@ class SyncManager: ObservableObject { private func performSync() async { NSLog("SyncManager: performSync starting") + activityLog.info("Starting sync...") isSyncing = true do { let conversations = try imessageReader.getConversations() NSLog("SyncManager: Found \(conversations.count) conversations") + activityLog.info("Found \(conversations.count) conversations") var totalSynced = 0 for conversation in conversations { @@ -213,6 +224,9 @@ class SyncManager: ObservableObject { let synced = try await apiClient.syncMessages(payload) totalSynced += synced NSLog("SyncManager: Synced \(synced) messages") + if synced > 0 { + activityLog.info("Synced \(synced) messages from \(conversation.displayName)") + } } let newSyncTime = Date() @@ -220,11 +234,18 @@ class SyncManager: ObservableObject { UserDefaults.standard.set(newSyncTime, forKey: "lastSync") NSLog("SyncManager: Sync complete - \(totalSynced) new messages synced, lastSync set to \(newSyncTime)") + if totalSynced > 0 { + activityLog.success("Sync complete: \(totalSynced) new messages") + } else { + activityLog.success("Sync complete: no new messages") + } + // Fetch accurate stats from server after sync await fetchStats() } catch { NSLog("SyncManager: Sync failed: \(error.localizedDescription)") + activityLog.error("Sync failed: \(error.localizedDescription)") } isSyncing = false @@ -233,10 +254,12 @@ class SyncManager: ObservableObject { private func syncContacts() async { NSLog("SyncManager: syncContacts starting") + activityLog.info("Syncing contacts...") do { let contacts = imessageReader.getAllContacts() guard !contacts.isEmpty else { NSLog("SyncManager: No contacts to sync") + activityLog.info("No contacts to sync") return } @@ -250,8 +273,10 @@ class SyncManager: ObservableObject { let synced = try await apiClient.syncContacts(payloads) NSLog("SyncManager: Synced \(synced) contacts to server") + activityLog.success("Synced \(synced) contacts") } catch { NSLog("SyncManager: syncContacts failed: \(error.localizedDescription)") + activityLog.error("Contact sync failed") } } @@ -296,12 +321,14 @@ class SyncManager: ObservableObject { private func performReset() async { NSLog("SyncManager: performReset starting") + activityLog.info("Starting full reset...") isResetting = true do { // 1. Call server to clear all synced data let result = try await apiClient.resetSync() NSLog("SyncManager: Server reset complete - deleted \(result.deletedMessages) messages, \(result.deletedConversations) conversations") + activityLog.info("Cleared \(result.deletedMessages) messages, \(result.deletedConversations) conversations") // 2. Clear local lastSync to trigger full resync lastSync = nil @@ -313,11 +340,13 @@ class SyncManager: ObservableObject { // 4. Trigger full resync NSLog("SyncManager: Starting full resync...") + activityLog.info("Starting full resync...") isResetting = false await performSync() } catch { NSLog("SyncManager: Reset failed: \(error.localizedDescription)") + activityLog.error("Reset failed: \(error.localizedDescription)") isResetting = false } } diff --git a/features/conversation-assistant/macos/Sources/Views/MenuBarView.swift b/features/conversation-assistant/macos/Sources/Views/MenuBarView.swift index d0c3c9496..833974008 100644 --- a/features/conversation-assistant/macos/Sources/Views/MenuBarView.swift +++ b/features/conversation-assistant/macos/Sources/Views/MenuBarView.swift @@ -122,6 +122,9 @@ struct MenuBarView: View { StatRow(label: "Last Sync", value: viewModel.lastSyncText) } .padding(.horizontal) + + // Activity Log + ActivityLogSection(entries: viewModel.activityLogEntries) } Spacer() @@ -229,6 +232,85 @@ struct StatRow: View { } } +struct ActivityLogSection: View { + let entries: [LogEntry] + + /// Maximum entries to display + private let maxDisplayed = 8 + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Activity") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + Spacer() + } + .padding(.horizontal) + .padding(.top, 8) + + if entries.isEmpty { + Text("No recent activity") + .font(.caption2) + .foregroundColor(.tertiary) + .padding(.horizontal) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(entries.prefix(maxDisplayed)) { entry in + ActivityLogRow(entry: entry) + } + } + } + .frame(maxHeight: 120) + .padding(.horizontal) + } + } + } +} + +struct ActivityLogRow: View { + let entry: LogEntry + + private var timeText: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: entry.timestamp, relativeTo: Date()) + } + + private var iconColor: Color { + switch entry.level { + case .info: return .blue + case .success: return .green + case .warning: return .orange + case .error: return .red + } + } + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: entry.level.icon) + .font(.caption2) + .foregroundColor(iconColor) + .frame(width: 12) + .accessibilityHidden(true) + + Text(entry.message) + .font(.caption2) + .foregroundColor(.primary) + .lineLimit(2) + + Spacer() + + Text(timeText) + .font(.caption2) + .foregroundColor(.tertiary) + } + .padding(.vertical, 2) + } +} + enum SyncStatus { case idle case syncing diff --git a/features/conversation-assistant/macos/Sources/Views/MenuBarViewModel.swift b/features/conversation-assistant/macos/Sources/Views/MenuBarViewModel.swift index cca5aa88f..45ce636e1 100644 --- a/features/conversation-assistant/macos/Sources/Views/MenuBarViewModel.swift +++ b/features/conversation-assistant/macos/Sources/Views/MenuBarViewModel.swift @@ -17,8 +17,11 @@ class MenuBarViewModel: ObservableObject { @Published var isRegistering = false @Published var authError: String? + @Published var activityLogEntries: [LogEntry] = [] + private let apiClient = APIClient.shared private let syncManager = SyncManager.shared + private let activityLog = ActivityLog.shared private var cancellables = Set() private var pollingTimer: Timer? @@ -76,6 +79,13 @@ class MenuBarViewModel: ObservableObject { } .store(in: &cancellables) + activityLog.$entries + .receive(on: DispatchQueue.main) + .sink { [weak self] entries in + self?.activityLogEntries = entries + } + .store(in: &cancellables) + // Register device on startup if not authenticated if !isAuthenticated { registerDevice()