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:
parent
36bcf89c87
commit
2f4d0e3e9e
4 changed files with 196 additions and 0 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue