feat(conversation-assistant): add activity log UI for real-time action visibility

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 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-29 23:46:17 -08:00
parent 36bcf89c87
commit 2f4d0e3e9e
4 changed files with 196 additions and 0 deletions

View file

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

View file

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

View file

@ -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

View file

@ -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<AnyCancellable>()
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()