From e1ff4d9f23655139e86d7d9aa3a4219a6f3efcc9 Mon Sep 17 00:00:00 2001 From: Aforno Date: Sat, 29 Aug 2026 13:10:12 +0100 Subject: [PATCH 1/3] feat(desktop): refine setup, Activity Center, and relaunch handoff - Hand a repeated launch to the running process and open Activity Center - Show provider connection state in Setup and Integrations - Let Active/Attention metrics filter sessions; keep completed and failed separate - Collapse tool lifecycle events that share a call identifier --- CHANGELOG.md | 12 +++ Sources/AgentsNotch/App/AgentsNotchApp.swift | 28 ++++++ .../App/AppInstanceCoordinator.swift | 94 +++++++++++++++++++ .../ProviderIntegrationManager.swift | 9 ++ .../Services/AgentNotificationService.swift | 2 +- .../ActivityCenter/ActivityCenterHeader.swift | 23 ++++- .../ActivityCenterProjection.swift | 14 ++- .../ActivityCenterSidebar.swift | 20 +++- .../ActivityCenter/ActivityCenterView.swift | 15 ++- .../ActivityEventTimeline.swift | 29 +++++- .../ActivityCenter/ActivitySessionRow.swift | 50 ++++++---- .../UI/Onboarding/OnboardingView.swift | 37 ++++++-- .../UI/Settings/IntegrationSettingsPane.swift | 23 ++--- .../UI/Settings/SettingsComponents.swift | 37 +++++++- .../UI/Settings/SettingsView.swift | 2 +- .../Protocol/AgentHookEventMapper.swift | 13 ++- .../Protocol/AgentHookPayload.swift | 10 +- .../ActivityCenterProjectionTests.swift | 41 +++++++- .../ActivityEventTimelineTests.swift | 35 ++++++- .../AgentHookEventMapperTests.swift | 36 +++++++ .../AppInstanceCoordinatorTests.swift | 64 +++++++++++++ .../HookRelayIntegrationTests.swift | 4 +- .../ProviderIntegrationManagerTests.swift | 10 ++ docs/PROTOCOL.md | 4 +- 24 files changed, 546 insertions(+), 66 deletions(-) create mode 100644 Sources/AgentsNotch/App/AppInstanceCoordinator.swift create mode 100644 Tests/AgentsNotchTests/AppInstanceCoordinatorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 02cbfb8..6f19e11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Update checks use the Sparkle appcast instead of opening the GitHub releases page. Automatic checks default on, run at launch and once a day, and never download or install until you ask. +- Setup and Integrations show each provider's connection state. Setup also + reports overall readiness and states when it will finish without a provider. +- Activity Center's Active and Attention metrics filter the session list. Its + status filters now keep completed and failed sessions separate. +- Tool lifecycle events with a shared call identifier render as one timeline + entry, even when another important event arrives between them. - Notch controls show hover and press. Waiting-prompt shortcuts activate after a click and remain active only while the pointer is over the prompt. - Primary actions pick black or white text from the system accent so light @@ -24,6 +30,12 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- A repeated app launch now opens Activity Center in the existing process and + exits before competing for the local event socket. +- Activity Center project headings no longer expose the decorative folder as + an unrelated VoiceOver action, and long session titles show in hover help. +- Swift 6 builds no longer warn about notification logging or the hook relay + test's trailing closure. - Retry after a Sparkle startup failure starts the updater again instead of doing nothing until relaunch. - An ineligible update (newer macOS required, and similar Sparkle reasons) no diff --git a/Sources/AgentsNotch/App/AgentsNotchApp.swift b/Sources/AgentsNotch/App/AgentsNotchApp.swift index d9003e8..8aa1eb9 100644 --- a/Sources/AgentsNotch/App/AgentsNotchApp.swift +++ b/Sources/AgentsNotch/App/AgentsNotchApp.swift @@ -5,14 +5,26 @@ import SwiftUI @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { let runtime = AppRuntime() + private let instanceCoordinator = AppInstanceCoordinator() private var panelController: NotchPanelController? private var activityCenterWindowController: ActivityCenterWindowController? private var onboardingWindowController: OnboardingWindowController? private var settingsWindowController: SettingsWindowController? private var globalShortcutController: GlobalActivityShortcutController? private var recoveryStatusItem: SurfaceRecoveryStatusItem? + private var handsOffLaunch = false + private var monitorsActivity = false + + func applicationWillFinishLaunching(_ notification: Notification) { + handsOffLaunch = instanceCoordinator.handOffIfNeeded() + } func applicationDidFinishLaunching(_ notification: Notification) { + guard !handsOffLaunch else { + NSApp.terminate(nil) + return + } + var defaults: [String: Any] = [ "animationsEnabled": true, "displayPreference": DisplayPreference.primary.rawValue, @@ -33,10 +45,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { #endif UserDefaults.standard.register(defaults: defaults) + instanceCoordinator.startReceiving { [weak self] in + self?.showActivityCenter() + } + NSApp.setActivationPolicy(.accessory) ProcessInfo.processInfo.disableAutomaticTermination( "Agent Notch monitors local agent activity" ) + monitorsActivity = true let panel = NotchPanelController(runtime: runtime) panelController = panel runtime.panelController = panel @@ -71,10 +88,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + instanceCoordinator.stopReceiving() + guard monitorsActivity else { return } runtime.stop() ProcessInfo.processInfo.enableAutomaticTermination( "Agent Notch monitors local agent activity" ) + monitorsActivity = false + } + + func applicationShouldHandleReopen( + _ sender: NSApplication, + hasVisibleWindows flag: Bool + ) -> Bool { + showActivityCenter() + return true } private func showActivityCenter() { diff --git a/Sources/AgentsNotch/App/AppInstanceCoordinator.swift b/Sources/AgentsNotch/App/AppInstanceCoordinator.swift new file mode 100644 index 0000000..4c29a0d --- /dev/null +++ b/Sources/AgentsNotch/App/AppInstanceCoordinator.swift @@ -0,0 +1,94 @@ +import AppKit +import Foundation + +struct RunningAgentNotchInstance { + let processIdentifier: pid_t + let launchDate: Date + let activate: () -> Bool +} + +/// Hands a repeated launch to the oldest running Agent Notch process. +@MainActor +final class AppInstanceCoordinator: NSObject { + static let activationNotification = Notification.Name( + "com.afonsoferreira.AgentNotch.activateExistingInstance" + ) + + private let currentProcessIdentifier: pid_t + private let runningInstances: () -> [RunningAgentNotchInstance] + private let postActivationRequest: (pid_t) -> Void + private let distributedCenter: DistributedNotificationCenter + private var onActivationRequest: (() -> Void)? + private var isObserving = false + + init( + currentProcessIdentifier: pid_t = ProcessInfo.processInfo.processIdentifier, + bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "com.afonsoferreira.AgentNotch", + distributedCenter: DistributedNotificationCenter = .default(), + runningInstances: (() -> [RunningAgentNotchInstance])? = nil, + postActivationRequest: ((pid_t) -> Void)? = nil + ) { + self.currentProcessIdentifier = currentProcessIdentifier + self.distributedCenter = distributedCenter + self.runningInstances = runningInstances ?? { + NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).map { application in + RunningAgentNotchInstance( + processIdentifier: application.processIdentifier, + launchDate: application.launchDate ?? .distantFuture, + activate: { + application.activate(options: [.activateAllWindows]) + } + ) + } + } + self.postActivationRequest = postActivationRequest ?? { processIdentifier in + distributedCenter.postNotificationName( + Self.activationNotification, + object: String(processIdentifier), + userInfo: nil, + deliverImmediately: true + ) + } + super.init() + } + + func handOffIfNeeded() -> Bool { + guard let existing = runningInstances() + .filter({ $0.processIdentifier != currentProcessIdentifier }) + .min(by: { $0.launchDate < $1.launchDate }) + else { return false } + + postActivationRequest(existing.processIdentifier) + _ = existing.activate() + return true + } + + func startReceiving(onActivationRequest: @escaping () -> Void) { + self.onActivationRequest = onActivationRequest + guard !isObserving else { return } + isObserving = true + distributedCenter.addObserver( + self, + selector: #selector(handleActivationRequest), + name: Self.activationNotification, + object: nil, + suspensionBehavior: .deliverImmediately + ) + } + + func stopReceiving() { + guard isObserving else { return } + distributedCenter.removeObserver( + self, + name: Self.activationNotification, + object: nil + ) + isObserving = false + onActivationRequest = nil + } + + @objc private func handleActivationRequest(_ notification: Notification) { + guard notification.object as? String == String(currentProcessIdentifier) else { return } + onActivationRequest?() + } +} diff --git a/Sources/AgentsNotch/Integrations/ProviderIntegrationManager.swift b/Sources/AgentsNotch/Integrations/ProviderIntegrationManager.swift index 51e33e8..96c1026 100644 --- a/Sources/AgentsNotch/Integrations/ProviderIntegrationManager.swift +++ b/Sources/AgentsNotch/Integrations/ProviderIntegrationManager.swift @@ -26,6 +26,15 @@ enum ProviderIntegrationStatus: Equatable { case .awaitingFirstEvent, .connected: false } } + + var isInstalled: Bool { + switch self { + case .awaitingFirstEvent, .connected: true + case .notInstalled, .unavailable: false + } + } + + var isConnected: Bool { self == .connected } } @Observable diff --git a/Sources/AgentsNotch/Services/AgentNotificationService.swift b/Sources/AgentsNotch/Services/AgentNotificationService.swift index 1639628..9aac045 100644 --- a/Sources/AgentsNotch/Services/AgentNotificationService.swift +++ b/Sources/AgentsNotch/Services/AgentNotificationService.swift @@ -11,7 +11,7 @@ final class AgentNotificationService: NSObject, UNUserNotificationCenterDelegate var onOpenSession: ((String) -> Void)? private let center: UNUserNotificationCenter? - private static let logger = Logger( + nonisolated private static let logger = Logger( subsystem: "com.afonsoferreira.AgentNotch", category: "notifications" ) diff --git a/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterHeader.swift b/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterHeader.swift index 2503d11..9c4c261 100644 --- a/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterHeader.swift +++ b/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterHeader.swift @@ -5,6 +5,7 @@ struct ActivityCenterHeader: View { let sessionCount: Int let activeCount: Int let attentionCount: Int + let statusFilter: Binding let groupingMode: Binding let canClearHistory: Bool let requestClearHistory: () -> Void @@ -20,8 +21,20 @@ struct ActivityCenterHeader: View { .foregroundStyle(NotchWindowPalette.secondaryText) } Spacer() - ActivityMetric(title: "Active", value: activeCount, color: .blue) - ActivityMetric(title: "Attention", value: attentionCount, color: .orange) + ActivityMetric( + title: "Active", + value: activeCount, + color: .blue, + isSelected: statusFilter.wrappedValue == .active, + action: { toggleStatusFilter(.active) } + ) + ActivityMetric( + title: "Attention", + value: attentionCount, + color: .orange, + isSelected: statusFilter.wrappedValue == .attention, + action: { toggleStatusFilter(.attention) } + ) Menu { Picker("Session Grouping", selection: groupingMode) { ForEach(ActivityGroupingMode.allCases) { mode in @@ -29,7 +42,7 @@ struct ActivityCenterHeader: View { } } Divider() - Button("Clear Completed History", role: .destructive, action: requestClearHistory) + Button("Clear Finished History", role: .destructive, action: requestClearHistory) .disabled(!canClearHistory) Divider() Button("Quit Agent Notch") { @@ -47,6 +60,10 @@ struct ActivityCenterHeader: View { .padding(.vertical, 14) .background(NotchWindowPalette.background) } + + private func toggleStatusFilter(_ filter: ActivityStatusFilter) { + statusFilter.wrappedValue = statusFilter.wrappedValue == filter ? .all : filter + } } struct ActivityCenterEmptyDetail: View { diff --git a/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterProjection.swift b/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterProjection.swift index fe2aac9..3b5f27e 100644 --- a/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterProjection.swift +++ b/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterProjection.swift @@ -3,7 +3,7 @@ import Foundation import Observation enum ActivityStatusFilter: String, CaseIterable, Identifiable { - case all, active, attention, completed + case all, active, attention, completed, failed var id: String { rawValue } var title: String { switch self { @@ -11,6 +11,7 @@ enum ActivityStatusFilter: String, CaseIterable, Identifiable { case .active: "Active" case .attention: "Attention" case .completed: "Completed" + case .failed: "Failed" } } } @@ -22,23 +23,25 @@ enum ActivityGroupingMode: String, CaseIterable, Identifiable { } enum ActivityDateFilter: String, CaseIterable, Identifiable { - case all, today, sevenDays, thirtyDays + case all, today, sevenDays var id: String { rawValue } var title: String { switch self { case .all: "Any time" case .today: "Today" case .sevenDays: "Last 7 days" - case .thirtyDays: "Last 30 days" } } + static func fromPersistedValue(_ rawValue: String) -> ActivityDateFilter { + rawValue == "thirtyDays" ? .sevenDays : ActivityDateFilter(rawValue: rawValue) ?? .all + } + func includes(_ date: Date, now: Date, calendar: Calendar = .current) -> Bool { switch self { case .all: true case .today: date >= calendar.startOfDay(for: now) case .sevenDays: date >= calendar.date(byAdding: .day, value: -7, to: now) ?? .distantPast - case .thirtyDays: date >= calendar.date(byAdding: .day, value: -30, to: now) ?? .distantPast } } } @@ -163,7 +166,8 @@ final class ActivityCenterProjection { case .all: true case .active: session.isActive case .attention: session.state == .waitingForUser - case .completed: !session.isActive + case .completed: session.state == .completed + case .failed: session.state == .failed } return statusMatches && (query.isEmpty || Self.searchText(for: session).localizedCaseInsensitiveContains(query)) }.sorted { $0.updatedAt != $1.updatedAt ? $0.updatedAt > $1.updatedAt : $0.id < $1.id } diff --git a/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterSidebar.swift b/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterSidebar.swift index 6e78b92..0b3aeaf 100644 --- a/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterSidebar.swift +++ b/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterSidebar.swift @@ -16,6 +16,7 @@ struct ActivityCenterSidebar: View { let onToggleGroup: (String) -> Void let onOpen: (AgentSession, OriginOpenAction) -> Void let onRemove: (String) -> Void + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(spacing: 0) { @@ -62,7 +63,13 @@ struct ActivityCenterSidebar: View { .scrollIndicators(.visible) .onChange(of: selection) { _, selectedID in guard let selectedID else { return } - withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(selectedID, anchor: .center) } + if reduceMotion { + proxy.scrollTo(selectedID, anchor: .center) + } else { + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo(selectedID, anchor: .center) + } + } } } } @@ -71,7 +78,9 @@ struct ActivityCenterSidebar: View { private func projectSection(_ project: ActivityProjectSection) -> some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { - Image(systemName: "folder").font(.system(size: 9, weight: .medium)) + Image(systemName: "folder") + .font(.system(size: 9, weight: .medium)) + .accessibilityHidden(true) Text(project.title).lineLimit(1) Spacer(minLength: 4) Text("\(project.sessionCount)").monospacedDigit() @@ -81,6 +90,12 @@ struct ActivityCenterSidebar: View { .padding(.horizontal, 10) .padding(.top, 8) .padding(.bottom, 4) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + project.sessionCount == 1 + ? "\(project.title), 1 session" + : "\(project.title), \(project.sessionCount) sessions" + ) ForEach(project.groups, content: sessionGroup) } } @@ -117,6 +132,7 @@ struct ActivityCenterSidebar: View { ActivitySessionRow(session: session, isSelected: selection == session.id) } .buttonStyle(.plain) + .help(session.task) .contextMenu { ForEach(OriginActivationService.destinations(for: session), id: \.action) { destination in Button(destination.title) { onOpen(session, destination.action) } diff --git a/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterView.swift b/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterView.swift index 66cf327..1a0e2f4 100644 --- a/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterView.swift +++ b/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterView.swift @@ -27,6 +27,7 @@ struct ActivityCenterView: View { sessionCount: projection.sessionCount, activeCount: runtime.activity.notchSnapshot.activeSessions.count, attentionCount: runtime.activity.notchSnapshot.attentionCount, + statusFilter: statusFilterBinding, groupingMode: groupingModeBinding, canClearHistory: projection.hasRecentSessions, requestClearHistory: { confirmsClearHistory = true } @@ -36,7 +37,7 @@ struct ActivityCenterView: View { HStack(spacing: 0) { sidebar - .frame(minWidth: 270, idealWidth: 300, maxWidth: 340) + .frame(minWidth: 280, idealWidth: 320, maxWidth: 420) // Arrow / return / delete need the column to take focus. // The default ring is a blue rectangle around the sidebar. .focusable() @@ -55,7 +56,7 @@ struct ActivityCenterView: View { .frame(minWidth: 760, minHeight: 500) .deepBlackWindowSurface() .confirmationDialog( - "Clear completed session history?", + "Clear finished session history?", isPresented: $confirmsClearHistory ) { Button("Clear History", role: .destructive) { runtime.clearHistory() } @@ -63,6 +64,7 @@ struct ActivityCenterView: View { Text("Active and waiting sessions will be kept.") } .onAppear { + normalizePersistedFilters() refreshProjection() synchronizeSelection() handleSearchRequest() @@ -157,7 +159,7 @@ struct ActivityCenterView: View { } private var dateFilter: ActivityDateFilter { - ActivityDateFilter(rawValue: dateFilterRaw) ?? .all + ActivityDateFilter.fromPersistedValue(dateFilterRaw) } private var groupingMode: ActivityGroupingMode { @@ -300,4 +302,11 @@ struct ActivityCenterView: View { isSessionListFocused = false isSearchFocused = true } + + private func normalizePersistedFilters() { + let normalizedDate = ActivityDateFilter.fromPersistedValue(dateFilterRaw).rawValue + if dateFilterRaw != normalizedDate { + dateFilterRaw = normalizedDate + } + } } diff --git a/Sources/AgentsNotch/UI/ActivityCenter/ActivityEventTimeline.swift b/Sources/AgentsNotch/UI/ActivityCenter/ActivityEventTimeline.swift index ef9ce91..f17da2a 100644 --- a/Sources/AgentsNotch/UI/ActivityCenter/ActivityEventTimeline.swift +++ b/Sources/AgentsNotch/UI/ActivityCenter/ActivityEventTimeline.swift @@ -13,16 +13,27 @@ struct ActivityEventSummary: Identifiable, Equatable { let startedAt: Date let endedAt: Date let operationCount: Int + let isFailure: Bool let events: [AgentEvent] var duration: TimeInterval { max(0, endedAt.timeIntervalSince(startedAt)) } static func make(from recentEvents: [AgentEvent]) -> [ActivityEventSummary] { var summaries: [ActivityEventSummary] = [] + var toolSummaryIndexByCallID: [String: Int] = [:] for event in recentEvents.sorted(by: { $0.timestamp < $1.timestamp }) { - if let tool = toolName(for: event), + if let tool = toolName(for: event), let callID = toolCallID(for: event) { + if let index = toolSummaryIndexByCallID[callID] { + let combined = summaries[index].events + [event] + summaries[index] = toolSummary(tool: tool, events: combined) + } else { + toolSummaryIndexByCallID[callID] = summaries.count + summaries.append(toolSummary(tool: tool, events: [event])) + } + } else if let tool = toolName(for: event), let last = summaries.last, last.kind == .tool, + toolCallID(for: last.events.last) == nil, last.events.last.flatMap(toolName(for:)) == tool { let combined = last.events + [event] @@ -37,17 +48,22 @@ struct ActivityEventSummary: Identifiable, Equatable { startedAt: event.timestamp, endedAt: event.timestamp, operationCount: 1, + isFailure: event.resolvedState == .failed, events: [event] )) } } - return summaries.reversed() + return summaries.sorted { + $0.endedAt != $1.endedAt ? $0.endedAt > $1.endedAt : $0.startedAt > $1.startedAt + } } private static func toolSummary(tool: String, events: [AgentEvent]) -> ActivityEventSummary { let completed = events.filter { $0.type == .toolCompleted }.count let started = events.filter { $0.type == .toolStarted }.count - let failed = events.contains { $0.resolvedState == .failed } + let failed = events.contains { + $0.resolvedState == .failed || $0.activity?.hasPrefix("Tool failed") == true + } let isRunning = events.last?.type == .toolStarted let verb = failed ? "Tool failed" : (isRunning ? "Using" : "Ran") return ActivityEventSummary( @@ -57,10 +73,15 @@ struct ActivityEventSummary: Identifiable, Equatable { startedAt: events.map(\.timestamp).min() ?? .distantPast, endedAt: events.map(\.timestamp).max() ?? .distantPast, operationCount: max(1, max(completed, started)), + isFailure: failed, events: events ) } + private static func toolCallID(for event: AgentEvent?) -> String? { + event?.metadata?["toolCallId"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + } + private static func toolName(for event: AgentEvent) -> String? { guard event.type == .toolStarted || event.type == .toolCompleted else { return nil } return event.metadata?["tool"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty @@ -156,6 +177,7 @@ struct ActivityEventTimeline: View { } private func symbol(for summary: ActivityEventSummary) -> String { + if summary.isFailure { return "xmark" } guard let event = summary.events.last else { return "circle" } return switch event.type { case .toolStarted, .toolCompleted: "terminal" @@ -168,6 +190,7 @@ struct ActivityEventTimeline: View { } private func color(for summary: ActivityEventSummary) -> Color { + if summary.isFailure { return .red } guard let state = summary.events.last?.resolvedState else { return NotchWindowPalette.tertiaryText } return agentStateColor(for: state) } diff --git a/Sources/AgentsNotch/UI/ActivityCenter/ActivitySessionRow.swift b/Sources/AgentsNotch/UI/ActivityCenter/ActivitySessionRow.swift index 3f3e8fb..70f95ec 100644 --- a/Sources/AgentsNotch/UI/ActivityCenter/ActivitySessionRow.swift +++ b/Sources/AgentsNotch/UI/ActivityCenter/ActivitySessionRow.swift @@ -5,26 +5,43 @@ struct ActivityMetric: View { let title: String let value: Int let color: Color + let isSelected: Bool + let action: () -> Void var body: some View { - HStack(spacing: 7) { - Circle() - .fill(value > 0 ? color : Color.white.opacity(0.18)) - .frame(width: 6, height: 6) - .shadow(color: color.opacity(value > 0 ? 0.55 : 0), radius: 4) + Button(action: action) { + HStack(spacing: 7) { + Circle() + .fill(value > 0 ? color : Color.white.opacity(0.18)) + .frame(width: 6, height: 6) + .accessibilityHidden(true) - Text(title) - .font(NotchWindowFont.caption) - .foregroundStyle(NotchWindowPalette.secondaryText) + Text(title) + .font(NotchWindowFont.caption) + .foregroundStyle(isSelected ? Color.white.opacity(0.92) : NotchWindowPalette.secondaryText) - Text("\(value)") - .font(.system(size: 11, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(value > 0 ? 0.9 : 0.4)) - .monospacedDigit() + Text("\(value)") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(value > 0 ? 0.9 : 0.4)) + .monospacedDigit() + } + .padding(.horizontal, 9) + .frame(height: 24) + .background( + isSelected ? color.opacity(0.2) : NotchWindowPalette.raised, + in: Capsule() + ) + .overlay { + if isSelected { + Capsule().strokeBorder(color.opacity(0.42), lineWidth: 0.6) + } + } } - .padding(.horizontal, 9) - .frame(height: 24) - .background(NotchWindowPalette.raised, in: Capsule()) + .buttonStyle(.plain) + .help(isSelected ? "Show all sessions" : "Show \(title.lowercased()) sessions") + .accessibilityLabel("\(title), \(value) sessions") + .accessibilityHint(isSelected ? "Show all sessions" : "Filter sessions") + .accessibilityAddTraits(isSelected ? .isSelected : []) } } @@ -33,6 +50,7 @@ struct ActivitySessionRow: View { let isSelected: Bool @State private var isHovering = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { HStack(spacing: 10) { @@ -76,7 +94,7 @@ struct ActivitySessionRow: View { ) .contentShape(RoundedRectangle(cornerRadius: NotchWindowMetrics.cardRadius, style: .continuous)) .onHover { isHovering = $0 } - .animation(.easeOut(duration: 0.12), value: isHovering) + .animation(reduceMotion ? nil : .easeOut(duration: 0.12), value: isHovering) } private var rowFill: Color { diff --git a/Sources/AgentsNotch/UI/Onboarding/OnboardingView.swift b/Sources/AgentsNotch/UI/Onboarding/OnboardingView.swift index 221f86d..23897ae 100644 --- a/Sources/AgentsNotch/UI/Onboarding/OnboardingView.swift +++ b/Sources/AgentsNotch/UI/Onboarding/OnboardingView.swift @@ -11,6 +11,7 @@ struct OnboardingView: View { Image(nsImage: NSApp.applicationIconImage) .resizable() .frame(width: 64, height: 64) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 5) { Text("Agents should find you—not interrupt you") @@ -20,6 +21,9 @@ struct OnboardingView: View { .font(NotchWindowFont.body) .foregroundStyle(NotchWindowPalette.secondaryText) .fixedSize(horizontal: false, vertical: true) + Text(connectionSummary) + .font(NotchWindowFont.footnoteEmphasis) + .foregroundStyle(NotchWindowPalette.secondaryText) } } @@ -47,13 +51,20 @@ struct OnboardingView: View { .padding(.horizontal, 14) .notchPanel(cornerRadius: NotchWindowMetrics.cardRadius) + Label( + "Observers run locally. Agent Notch does not upload source code or session history.", + systemImage: "lock" + ) + .font(NotchWindowFont.footnote) + .foregroundStyle(NotchWindowPalette.secondaryText) + Spacer() HStack { Button("Open Activity Center") { runtime.openActivityCenter() } .buttonStyle(NotchPillButtonStyle()) Spacer() - Button("Finish") { + Button(finishButtonTitle) { UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding") onDone() } @@ -73,14 +84,11 @@ struct OnboardingView: View { .frame(width: 26) VStack(alignment: .leading, spacing: 3) { - Text(integration.provider.displayName) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.white.opacity(0.86)) - - if case let .unavailable(message) = integration.status { - Text(message) - .font(NotchWindowFont.caption) - .foregroundStyle(NotchWindowPalette.secondaryText) + HStack(spacing: 8) { + Text(integration.provider.displayName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.white.opacity(0.86)) + ProviderIntegrationStatusView(status: integration.status) } if let instructions = integration.trustInstructions { @@ -122,4 +130,15 @@ struct OnboardingView: View { .controlSize(.small) .padding(.vertical, 11) } + + private var finishButtonTitle: String { + runtime.integrations.contains { $0.status.isInstalled } + ? "Finish Setup" + : "Finish without Connecting" + } + + private var connectionSummary: String { + let connected = runtime.integrations.filter { $0.status.isConnected }.count + return "\(connected) of \(runtime.integrations.count) connected" + } } diff --git a/Sources/AgentsNotch/UI/Settings/IntegrationSettingsPane.swift b/Sources/AgentsNotch/UI/Settings/IntegrationSettingsPane.swift index 31bed57..05059d6 100644 --- a/Sources/AgentsNotch/UI/Settings/IntegrationSettingsPane.swift +++ b/Sources/AgentsNotch/UI/Settings/IntegrationSettingsPane.swift @@ -8,7 +8,7 @@ struct IntegrationSettingsPane: View { VStack(alignment: .leading, spacing: NotchWindowMetrics.sectionSpacing) { SettingsHeading( title: "Integrations", - detail: "Connect local coding agents to Agent Notch." + detail: connectionSummary ) runtimeHealthMessages VStack(spacing: 0) { @@ -48,16 +48,11 @@ struct IntegrationSettingsPane: View { ProviderIconView(provider: integration.provider, size: 20) .frame(width: 24, height: 24) VStack(alignment: .leading, spacing: 3) { - Text(integration.provider.displayName) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.white.opacity(0.86)) - if case .unavailable = integration.status { - HStack(spacing: 5) { - Circle().fill(.red).frame(width: 6, height: 6) - Text(integration.status.title) - } - .font(NotchWindowFont.caption) - .foregroundStyle(NotchWindowPalette.secondaryText) + HStack(spacing: 8) { + Text(integration.provider.displayName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.white.opacity(0.86)) + ProviderIntegrationStatusView(status: integration.status) } if let instructions = integration.trustInstructions { Text(instructions) @@ -95,4 +90,10 @@ struct IntegrationSettingsPane: View { .controlSize(.small) .padding(.vertical, 9) } + + private var connectionSummary: String { + let connected = runtime.integrations.filter { $0.status.isConnected }.count + let total = runtime.integrations.count + return "\(connected) of \(total) connected. Observers stay local to this Mac." + } } diff --git a/Sources/AgentsNotch/UI/Settings/SettingsComponents.swift b/Sources/AgentsNotch/UI/Settings/SettingsComponents.swift index d985506..82c6988 100644 --- a/Sources/AgentsNotch/UI/Settings/SettingsComponents.swift +++ b/Sources/AgentsNotch/UI/Settings/SettingsComponents.swift @@ -131,7 +131,7 @@ struct HistorySettingsSection: View { var body: some View { SettingsSection(title: "Local History") { SettingsMenuRow( - title: "Keep completed sessions", + title: "Keep finished sessions", detail: "How long finished sessions remain in Activity Center.", selection: retentionDays, options: [ @@ -141,7 +141,7 @@ struct HistorySettingsSection: View { ) SettingsControlRow( title: "History actions", - detail: "Open related windows or clear completed local sessions." + detail: "Open related windows or clear finished local sessions." ) { HStack(spacing: 8) { Button("Open Activity Center", action: openActivityCenter) @@ -320,6 +320,39 @@ struct SettingsMessage: View { } } +/// Compact provider health shared by Setup and Integrations. +struct ProviderIntegrationStatusView: View { + let status: ProviderIntegrationStatus + + var body: some View { + HStack(spacing: 5) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + .accessibilityHidden(true) + Text(status.title) + .lineLimit(1) + } + .font(NotchWindowFont.footnoteEmphasis) + .foregroundStyle(color) + .accessibilityElement(children: .combine) + .accessibilityLabel("Integration status: \(status.title)") + } + + private var color: Color { + switch status { + case .notInstalled: + NotchWindowPalette.tertiaryText + case .awaitingFirstEvent: + .orange + case .connected: + .green + case .unavailable: + .red + } + } +} + struct RuntimeHealthMessages: View { let socketError: String? let persistenceError: String? diff --git a/Sources/AgentsNotch/UI/Settings/SettingsView.swift b/Sources/AgentsNotch/UI/Settings/SettingsView.swift index 34bba55..33d10f4 100644 --- a/Sources/AgentsNotch/UI/Settings/SettingsView.swift +++ b/Sources/AgentsNotch/UI/Settings/SettingsView.swift @@ -54,7 +54,7 @@ struct SettingsView: View { .onChange(of: privacyModeEnabled) { _, enabled in runtime.applyPrivacyModeEnabled(enabled) } - .confirmationDialog("Clear completed session history?", isPresented: $confirmsClearHistory) { + .confirmationDialog("Clear finished session history?", isPresented: $confirmsClearHistory) { Button("Clear History", role: .destructive) { runtime.clearHistory() } } message: { Text("Active and waiting sessions will be kept.") diff --git a/Sources/AgentsNotchCore/Protocol/AgentHookEventMapper.swift b/Sources/AgentsNotchCore/Protocol/AgentHookEventMapper.swift index 4457a8d..3db5d6c 100644 --- a/Sources/AgentsNotchCore/Protocol/AgentHookEventMapper.swift +++ b/Sources/AgentsNotchCore/Protocol/AgentHookEventMapper.swift @@ -67,6 +67,7 @@ public enum AgentHookEventMapper { let metadata = [ "model": payload.model, "turnId": payload.turnId, + "toolCallId": payload.toolCallId, // Store the canonical lifecycle name when the provider uses an // alias (e.g. Gemini BeforeAgent → UserPromptSubmit) so session // resume and other hookEvent gates stay provider-neutral. @@ -116,12 +117,20 @@ public enum AgentHookEventMapper { return toolEvent(payload, completed: true, context: context) case .postToolUseFailure: - return context.event( + return AgentEvent( type: .toolCompleted, + sessionId: context.sessionId, + provider: context.provider, activity: payload.error.map { "Tool failed: \(ProviderEventPolicy.concise($0, limit: 76))" } ?? "Tool failed", - state: .running + state: .running, + timestamp: context.now, + workingDirectory: context.workingDirectory, + metadata: context.metadata.merging( + ["tool": payload.toolName].compactMapValues { $0 }, + uniquingKeysWith: { _, new in new } + ) ) case .permissionRequest where permissionRequestRequiresUserInput: diff --git a/Sources/AgentsNotchCore/Protocol/AgentHookPayload.swift b/Sources/AgentsNotchCore/Protocol/AgentHookPayload.swift index 8130346..f5dccc5 100644 --- a/Sources/AgentsNotchCore/Protocol/AgentHookPayload.swift +++ b/Sources/AgentsNotchCore/Protocol/AgentHookPayload.swift @@ -32,6 +32,7 @@ public struct AgentHookPayload: Decodable, Sendable { public var source: String? public var reason: String? public var toolName: String? + public var toolCallId: String? public var toolInput: JSONValue? public var agentId: String? public var agentType: String? @@ -47,7 +48,7 @@ public struct AgentHookPayload: Decodable, Sendable { // Grok camelCase case sessionId, transcriptPath, cwd, workspaceRoot, hookEventName case model, turnId, approvalsReviewer, prompt, source, reason, status - case toolName, toolInput, agentId, agentType, parentSessionId + case toolName, toolUseId, toolCallId, toolInput, agentId, agentType, parentSessionId case description, lastAssistantMessage, notificationType case notificationMessage = "message" case promptResponse, error, timestamp, createdAt @@ -61,6 +62,8 @@ public struct AgentHookPayload: Decodable, Sendable { case turnIdSnake = "turn_id" case approvalsReviewerSnake = "approvals_reviewer" case toolNameSnake = "tool_name" + case toolUseIdSnake = "tool_use_id" + case toolCallIdSnake = "tool_call_id" case toolInputSnake = "tool_input" case agentIdSnake = "agent_id" case agentTypeSnake = "agent_type" @@ -125,6 +128,11 @@ public struct AgentHookPayload: Decodable, Sendable { reason = try values.decodeIfPresent(String.self, forKey: .reason) ?? values.decodeIfPresent(String.self, forKey: .status) toolName = try values.decodeEitherIfPresent(String.self, forKey: .toolName, or: .toolNameSnake) + let camelToolUseId = try values.decodeIfPresent(String.self, forKey: .toolUseId)?.nonEmpty + let camelToolCallId = try values.decodeIfPresent(String.self, forKey: .toolCallId)?.nonEmpty + let snakeToolUseId = try values.decodeIfPresent(String.self, forKey: .toolUseIdSnake)?.nonEmpty + let snakeToolCallId = try values.decodeIfPresent(String.self, forKey: .toolCallIdSnake)?.nonEmpty + toolCallId = camelToolUseId ?? camelToolCallId ?? snakeToolUseId ?? snakeToolCallId toolInput = try values.decodeEitherIfPresent(JSONValue.self, forKey: .toolInput, or: .toolInputSnake) agentId = try values.decodeEitherIfPresent(String.self, forKey: .agentId, or: .agentIdSnake) ?? values.decodeIfPresent(String.self, forKey: .subagentIdSnake) diff --git a/Tests/AgentsNotchTests/ActivityCenterProjectionTests.swift b/Tests/AgentsNotchTests/ActivityCenterProjectionTests.swift index 15c1fd2..f690528 100644 --- a/Tests/AgentsNotchTests/ActivityCenterProjectionTests.swift +++ b/Tests/AgentsNotchTests/ActivityCenterProjectionTests.swift @@ -104,6 +104,42 @@ final class ActivityCenterProjectionTests: XCTestCase { XCTAssertEqual(Set(projection.availableProjects.map(\.title)), ["Recent", "Old"]) } + func testStatusFiltersKeepCompletedAndFailedDistinct() { + let now = Date(timeIntervalSince1970: 1_100_000) + let sessions = [ + makeSession(id: "running", task: "Running", timestamp: now, directory: "/tmp/Status", state: .running), + makeSession(id: "waiting", task: "Waiting", timestamp: now, directory: "/tmp/Status", state: .waitingForUser), + makeSession(id: "completed", task: "Completed", timestamp: now, directory: "/tmp/Status", state: .completed), + makeSession(id: "failed", task: "Failed", timestamp: now, directory: "/tmp/Status", state: .failed), + makeSession(id: "idle", task: "Idle", timestamp: now, directory: "/tmp/Status", state: .idle), + ] + let projection = ActivityCenterProjection() + + let expectations: [(ActivityStatusFilter, Set)] = [ + (.active, ["running", "waiting"]), + (.attention, ["waiting"]), + (.completed, ["completed"]), + (.failed, ["failed"]), + ] + for (filter, expectedIDs) in expectations { + projection.update( + sessions: sessions, + searchText: "", + providerFilter: "all", + statusFilter: filter, + now: now + ) + XCTAssertEqual(Set(projection.filteredSessions.map(\.id)), expectedIDs, "Wrong results for \(filter)") + } + } + + func testLegacyThirtyDayFilterMigratesToSevenDays() { + XCTAssertEqual(ActivityDateFilter.fromPersistedValue("thirtyDays"), .sevenDays) + XCTAssertEqual(ActivityDateFilter.fromPersistedValue("today"), .today) + XCTAssertEqual(ActivityDateFilter.fromPersistedValue("invalid"), .all) + XCTAssertEqual(ActivityDateFilter.allCases, [.all, .today, .sevenDays]) + } + func testLegacyProviderAliasesShareOneFilterOptionAndMatchCanonicalSelection() { let now = Date(timeIntervalSince1970: 30_000) var legacy = makeSession( @@ -386,7 +422,8 @@ final class ActivityCenterProjectionTests: XCTestCase { task: String, timestamp: Date, directory: String, - parentID: String? = nil + parentID: String? = nil, + state: AgentState = .running ) -> AgentSession { AgentSession(event: AgentEvent( type: .activity, @@ -394,7 +431,7 @@ final class ActivityCenterProjectionTests: XCTestCase { provider: .codex, task: task, activity: "Working", - state: .running, + state: state, timestamp: timestamp, workingDirectory: directory, parentSessionId: parentID diff --git a/Tests/AgentsNotchTests/ActivityEventTimelineTests.swift b/Tests/AgentsNotchTests/ActivityEventTimelineTests.swift index 6a787a4..69b8e10 100644 --- a/Tests/AgentsNotchTests/ActivityEventTimelineTests.swift +++ b/Tests/AgentsNotchTests/ActivityEventTimelineTests.swift @@ -32,6 +32,32 @@ final class ActivityEventTimelineTests: XCTestCase { XCTAssertEqual(summaries[1].title, "Needs approval") } + func testToolCallIdentityPairsLifecycleAcrossImportantEvent() { + let start = Date(timeIntervalSince1970: 100) + let events = [ + event(.toolCompleted, at: start.addingTimeInterval(2), activity: "Finished js", callID: "call-1"), + event(.waiting, at: start.addingTimeInterval(1), activity: "Needs approval"), + event(.toolStarted, at: start, activity: "Using js", callID: "call-1"), + ] + + let summaries = ActivityEventSummary.make(from: events) + + XCTAssertEqual(summaries.count, 2) + XCTAssertEqual(summaries[0].title, "Ran JavaScript") + XCTAssertEqual(summaries[0].events.count, 2) + XCTAssertEqual(summaries[0].duration, 2) + XCTAssertEqual(summaries[1].title, "Needs approval") + } + + func testFailedToolSummaryCarriesFailurePresentation() { + let summary = ActivityEventSummary.make(from: [ + event(.toolCompleted, at: Date(timeIntervalSince1970: 100), activity: "Tool failed: Tests failed"), + ]) + + XCTAssertEqual(summary.first?.title, "Tool failed JavaScript") + XCTAssertTrue(summary.first?.isFailure == true) + } + func testWhitespaceOnlyToolMetadataIsIgnored() { let events = [ AgentEvent( @@ -54,15 +80,18 @@ final class ActivityEventTimelineTests: XCTestCase { private func event( _ type: AgentEventType, at timestamp: Date, - activity: String + activity: String, + callID: String? = nil ) -> AgentEvent { - AgentEvent( + var metadata = ["tool": "mcp__node_repl__js"] + metadata["toolCallId"] = callID + return AgentEvent( type: type, sessionId: "codex:test", provider: .codex, activity: activity, timestamp: timestamp, - metadata: ["tool": "mcp__node_repl__js"] + metadata: metadata ) } } diff --git a/Tests/AgentsNotchTests/AgentHookEventMapperTests.swift b/Tests/AgentsNotchTests/AgentHookEventMapperTests.swift index 7f35d8d..0d1c03b 100644 --- a/Tests/AgentsNotchTests/AgentHookEventMapperTests.swift +++ b/Tests/AgentsNotchTests/AgentHookEventMapperTests.swift @@ -446,6 +446,42 @@ final class AgentHookEventMapperTests: XCTestCase { XCTAssertEqual(event.activity, "Tool failed: Tests failed") } + func testToolCallIdentifierAliasesReachEventMetadata() throws { + let aliases = ["toolUseId", "toolCallId", "tool_use_id", "tool_call_id"] + for alias in aliases { + let payload = try decode(""" + { + "session_id": "tool-call-alias", + "cwd": "/tmp/AgentsNotch", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "\(alias)": "call-123", + "tool_input": {"command": "swift test"} + } + """) + + let event = try XCTUnwrap(AgentHookEventMapper.map(payload, provider: .claudeCode)) + XCTAssertEqual(event.metadata?["toolCallId"], "call-123", "Missing alias \(alias)") + } + } + + func testToolFailureKeepsToolAndCallIdentity() throws { + let payload = try decode(""" + { + "session_id": "claude_123", + "cwd": "/tmp/AgentsNotch", + "hook_event_name": "PostToolUseFailure", + "tool_name": "Bash", + "tool_use_id": "call-failed", + "error": "Tests failed" + } + """) + + let event = try XCTUnwrap(AgentHookEventMapper.map(payload, provider: .claudeCode)) + XCTAssertEqual(event.metadata?["tool"], "Bash") + XCTAssertEqual(event.metadata?["toolCallId"], "call-failed") + } + func testSessionStartUsesMainRepositoryNameForGitWorktrees() throws { let fixture = try LinkedGitWorktree.make() defer { try? FileManager.default.removeItem(at: fixture.root) } diff --git a/Tests/AgentsNotchTests/AppInstanceCoordinatorTests.swift b/Tests/AgentsNotchTests/AppInstanceCoordinatorTests.swift new file mode 100644 index 0000000..964317b --- /dev/null +++ b/Tests/AgentsNotchTests/AppInstanceCoordinatorTests.swift @@ -0,0 +1,64 @@ +@testable import AgentsNotch +import Foundation +import XCTest + +@MainActor +final class AppInstanceCoordinatorTests: XCTestCase { + func testRepeatedLaunchHandsOffToOldestExistingInstance() { + let recorder = ProcessActivationRecorder() + var activationRequestProcessIdentifiers: [pid_t] = [] + let coordinator = AppInstanceCoordinator( + currentProcessIdentifier: 30, + runningInstances: { + [ + self.instance(30, launchedAt: 30, recorder: recorder), + self.instance(20, launchedAt: 20, recorder: recorder), + self.instance(10, launchedAt: 10, recorder: recorder), + ] + }, + postActivationRequest: { activationRequestProcessIdentifiers.append($0) } + ) + + XCTAssertTrue(coordinator.handOffIfNeeded()) + XCTAssertEqual(recorder.processIdentifiers, [10]) + XCTAssertEqual(activationRequestProcessIdentifiers, [10]) + } + + func testFirstLaunchContinuesWithoutPostingActivationRequest() { + var activationRequestProcessIdentifiers: [pid_t] = [] + let coordinator = AppInstanceCoordinator( + currentProcessIdentifier: 30, + runningInstances: { + [RunningAgentNotchInstance( + processIdentifier: 30, + launchDate: Date(), + activate: { true } + )] + }, + postActivationRequest: { activationRequestProcessIdentifiers.append($0) } + ) + + XCTAssertFalse(coordinator.handOffIfNeeded()) + XCTAssertTrue(activationRequestProcessIdentifiers.isEmpty) + } + + private func instance( + _ processIdentifier: pid_t, + launchedAt: TimeInterval, + recorder: ProcessActivationRecorder + ) -> RunningAgentNotchInstance { + RunningAgentNotchInstance( + processIdentifier: processIdentifier, + launchDate: Date(timeIntervalSince1970: launchedAt), + activate: { + recorder.processIdentifiers.append(processIdentifier) + return true + } + ) + } +} + +@MainActor +private final class ProcessActivationRecorder { + var processIdentifiers: [pid_t] = [] +} diff --git a/Tests/AgentsNotchTests/HookRelayIntegrationTests.swift b/Tests/AgentsNotchTests/HookRelayIntegrationTests.swift index 1a4e090..a4b1bb9 100644 --- a/Tests/AgentsNotchTests/HookRelayIntegrationTests.swift +++ b/Tests/AgentsNotchTests/HookRelayIntegrationTests.swift @@ -33,7 +33,9 @@ final class HookRelayIntegrationTests: XCTestCase { candidates.append(url) } // Prefer the plain-debug binary. - guard let binary = candidates.first { $0.path.contains("/debug/") } ?? candidates.first else { + guard let binary = candidates.first(where: { $0.path.contains("/debug/") }) + ?? candidates.first + else { throw XCTSkip("AgentsNotchHook executable not found under .build") } return binary diff --git a/Tests/AgentsNotchTests/ProviderIntegrationManagerTests.swift b/Tests/AgentsNotchTests/ProviderIntegrationManagerTests.swift index 15d828c..4ac25fd 100644 --- a/Tests/AgentsNotchTests/ProviderIntegrationManagerTests.swift +++ b/Tests/AgentsNotchTests/ProviderIntegrationManagerTests.swift @@ -4,6 +4,16 @@ import Foundation import XCTest final class ProviderIntegrationManagerTests: XCTestCase { + func testStatusReadinessFlagsMatchLifecycle() { + XCTAssertFalse(ProviderIntegrationStatus.notInstalled.isInstalled) + XCTAssertFalse(ProviderIntegrationStatus.notInstalled.isConnected) + XCTAssertTrue(ProviderIntegrationStatus.awaitingFirstEvent.isInstalled) + XCTAssertFalse(ProviderIntegrationStatus.awaitingFirstEvent.isConnected) + XCTAssertTrue(ProviderIntegrationStatus.connected.isInstalled) + XCTAssertTrue(ProviderIntegrationStatus.connected.isConnected) + XCTAssertFalse(ProviderIntegrationStatus.unavailable("Unavailable").isInstalled) + } + @MainActor func testInstallIsIdempotentAndUninstallPreservesExistingConfiguration() throws { let fixture = try Fixture() diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 52d5273..68061eb 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -105,6 +105,9 @@ is the same channel the built-in hooks use. 3. Set `workingDirectory` so the session groups under the right project, and `timestamp` (ISO 8601) on every event. Events with future timestamps are clamped on ingest. + When tool lifecycle events expose a stable call identifier, place it in + `metadata.toolCallId` so Activity Center can pair start and completion even + when another event arrives between them. 4. Encode with the same conventions as `JSONEncoder.agentsNotch`: one JSON object per line, sorted keys optional, dates as ISO 8601 strings. Payloads over 1 MiB are rejected. @@ -112,4 +115,3 @@ is the same channel the built-in hooks use. Reference implementations live in `Sources/AgentsNotchCore/Protocol/` (`AgentHookEventMapper` converts each supported provider's native payloads into these events) and the bundled relay in `Sources/AgentsNotchHook/`. - From 300e861ad73245401e119280d38f332f38922495 Mon Sep 17 00:00:00 2001 From: Aforno Date: Sat, 29 Aug 2026 14:43:39 +0100 Subject: [PATCH 2/3] fix(desktop): keep overlapping launches from both quitting - Hand off only to a strictly older process, with an exclusive lock as tie-breaker - Count Active and Attention chips against the current filters --- CHANGELOG.md | 7 +- Sources/AgentsNotch/App/AgentsNotchApp.swift | 32 +++++- .../App/AppInstanceCoordinator.swift | 104 ++++++++++++++++-- .../ActivityCenterProjection.swift | 22 +++- .../ActivityCenter/ActivityCenterView.swift | 4 +- .../ActivityCenterProjectionTests.swift | 62 ++++++++++- .../AppInstanceCoordinatorTests.swift | 92 +++++++++++++++- 7 files changed, 297 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f19e11..1833749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,12 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - A repeated app launch now opens Activity Center in the existing process and - exits before competing for the local event socket. + exits before competing for the local event socket. Two overlapping launches + no longer both quit: only a strictly older process is treated as the owner, + and an exclusive lock breaks remaining ties. +- Activity Center's Active and Attention counts follow the current provider, + project, date, and search filters, so clicking a chip matches the number it + shows. - Activity Center project headings no longer expose the decorative folder as an unrelated VoiceOver action, and long session titles show in hover help. - Swift 6 builds no longer warn about notification logging or the hook relay diff --git a/Sources/AgentsNotch/App/AgentsNotchApp.swift b/Sources/AgentsNotch/App/AgentsNotchApp.swift index 8aa1eb9..0e793da 100644 --- a/Sources/AgentsNotch/App/AgentsNotchApp.swift +++ b/Sources/AgentsNotch/App/AgentsNotchApp.swift @@ -5,7 +5,9 @@ import SwiftUI @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { let runtime = AppRuntime() - private let instanceCoordinator = AppInstanceCoordinator() + private let instanceCoordinator = AppInstanceCoordinator( + ownershipLock: FileInstanceOwnershipLock() + ) private var panelController: NotchPanelController? private var activityCenterWindowController: ActivityCenterWindowController? private var onboardingWindowController: OnboardingWindowController? @@ -14,9 +16,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var recoveryStatusItem: SurfaceRecoveryStatusItem? private var handsOffLaunch = false private var monitorsActivity = false + private var canPresentWindows = false + private var pendingActivationRequest = false func applicationWillFinishLaunching(_ notification: Notification) { + // Listen before the handoff decision so a slightly later peer can still + // wake this process. The dying process stops observing immediately. + instanceCoordinator.startReceiving { [weak self] in + self?.handleExistingInstanceActivation() + } handsOffLaunch = instanceCoordinator.handOffIfNeeded() + if handsOffLaunch { + instanceCoordinator.stopReceiving() + } } func applicationDidFinishLaunching(_ notification: Notification) { @@ -45,10 +57,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { #endif UserDefaults.standard.register(defaults: defaults) - instanceCoordinator.startReceiving { [weak self] in - self?.showActivityCenter() - } - NSApp.setActivationPolicy(.accessory) ProcessInfo.processInfo.disableAutomaticTermination( "Agent Notch monitors local agent activity" @@ -85,6 +93,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if !UserDefaults.standard.bool(forKey: "hasCompletedOnboarding") { showOnboarding() } + canPresentWindows = true + if pendingActivationRequest { + pendingActivationRequest = false + showActivityCenter() + } } func applicationWillTerminate(_ notification: Notification) { @@ -105,6 +118,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return true } + private func handleExistingInstanceActivation() { + guard !handsOffLaunch else { return } + guard canPresentWindows else { + pendingActivationRequest = true + return + } + showActivityCenter() + } + private func showActivityCenter() { let controller = activityCenterWindowController ?? ActivityCenterWindowController(runtime: runtime) controller.onClose = { [weak self] in self?.activityCenterWindowController = nil } diff --git a/Sources/AgentsNotch/App/AppInstanceCoordinator.swift b/Sources/AgentsNotch/App/AppInstanceCoordinator.swift index 4c29a0d..a94143a 100644 --- a/Sources/AgentsNotch/App/AppInstanceCoordinator.swift +++ b/Sources/AgentsNotch/App/AppInstanceCoordinator.swift @@ -1,4 +1,5 @@ import AppKit +import Darwin import Foundation struct RunningAgentNotchInstance { @@ -7,7 +8,55 @@ struct RunningAgentNotchInstance { let activate: () -> Bool } -/// Hands a repeated launch to the oldest running Agent Notch process. +/// Exclusive lock so two overlapping launches cannot both become the owner. +/// The fcntl lock is released when the process exits, including crashes. +final class FileInstanceOwnershipLock { + private let fd: Int32? + private var holdsLock = false + + static var defaultFileURL: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("AgentNotch", isDirectory: true) + .appendingPathComponent("instance.lock") + } + + init(fileURL: URL = FileInstanceOwnershipLock.defaultFileURL) { + let directory = fileURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fd = Darwin.open(fileURL.path, O_CREAT | O_RDWR, mode_t(0o600)) + self.fd = fd >= 0 ? fd : nil + } + + deinit { + if holdsLock, let fd { + var lock = flock() + lock.l_type = Int16(F_UNLCK) + _ = Darwin.fcntl(fd, F_SETLK, &lock) + } + if let fd { + Darwin.close(fd) + } + } + + /// Returns false when another process already owns the instance lock. + /// A missing lock file does not block launch; launch-date ordering still applies. + /// `fcntl` locks are per-process, so two objects in the same process do not contend. + func tryAcquire() -> Bool { + guard !holdsLock else { return true } + guard let fd else { return true } + var lock = flock() + lock.l_type = Int16(F_WRLCK) + lock.l_whence = Int16(SEEK_SET) + if Darwin.fcntl(fd, F_SETLK, &lock) == 0 { + holdsLock = true + return true + } + let error = errno + return error != EAGAIN && error != EACCES + } +} + +/// Hands a repeated launch to a strictly older running Agent Notch process. @MainActor final class AppInstanceCoordinator: NSObject { static let activationNotification = Notification.Name( @@ -16,7 +65,8 @@ final class AppInstanceCoordinator: NSObject { private let currentProcessIdentifier: pid_t private let runningInstances: () -> [RunningAgentNotchInstance] - private let postActivationRequest: (pid_t) -> Void + private let postActivationRequest: (pid_t?) -> Void + private let tryAcquireOwnership: () -> Bool private let distributedCenter: DistributedNotificationCenter private var onActivationRequest: (() -> Void)? private var isObserving = false @@ -26,7 +76,9 @@ final class AppInstanceCoordinator: NSObject { bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "com.afonsoferreira.AgentNotch", distributedCenter: DistributedNotificationCenter = .default(), runningInstances: (() -> [RunningAgentNotchInstance])? = nil, - postActivationRequest: ((pid_t) -> Void)? = nil + postActivationRequest: ((pid_t?) -> Void)? = nil, + tryAcquireOwnership: (() -> Bool)? = nil, + ownershipLock: FileInstanceOwnershipLock? = nil ) { self.currentProcessIdentifier = currentProcessIdentifier self.distributedCenter = distributedCenter @@ -44,22 +96,50 @@ final class AppInstanceCoordinator: NSObject { self.postActivationRequest = postActivationRequest ?? { processIdentifier in distributedCenter.postNotificationName( Self.activationNotification, - object: String(processIdentifier), + object: processIdentifier.map(String.init), userInfo: nil, deliverImmediately: true ) } + self.tryAcquireOwnership = tryAcquireOwnership + ?? { ownershipLock?.tryAcquire() ?? true } super.init() } + /// Yields only to a process that launched earlier than this one. A newer + /// peer is not "the existing instance", so two overlapping launches cannot + /// both hand off and quit. The ownership lock is the tie-breaker when + /// launch dates are missing or a peer already claimed ownership. func handOffIfNeeded() -> Bool { - guard let existing = runningInstances() - .filter({ $0.processIdentifier != currentProcessIdentifier }) + let instances = runningInstances() + let currentLaunchDate = instances + .first { $0.processIdentifier == currentProcessIdentifier }? + .launchDate + let peers = instances.filter { $0.processIdentifier != currentProcessIdentifier } + + if let currentLaunchDate, + let existing = peers + .filter({ $0.launchDate < currentLaunchDate }) .min(by: { $0.launchDate < $1.launchDate }) - else { return false } + { + return handOff(to: existing) + } - postActivationRequest(existing.processIdentifier) - _ = existing.activate() + if tryAcquireOwnership() { + return false + } + + if let peer = peers.min(by: { $0.launchDate < $1.launchDate }) { + return handOff(to: peer) + } + + postActivationRequest(nil) + return true + } + + private func handOff(to instance: RunningAgentNotchInstance) -> Bool { + postActivationRequest(instance.processIdentifier) + _ = instance.activate() return true } @@ -88,7 +168,11 @@ final class AppInstanceCoordinator: NSObject { } @objc private func handleActivationRequest(_ notification: Notification) { - guard notification.object as? String == String(currentProcessIdentifier) else { return } + if let object = notification.object as? String, + object != String(currentProcessIdentifier) + { + return + } onActivationRequest?() } } diff --git a/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterProjection.swift b/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterProjection.swift index 3b5f27e..06a0078 100644 --- a/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterProjection.swift +++ b/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterProjection.swift @@ -72,7 +72,9 @@ final class ActivityCenterProjection { let projectGroups: [ActivityProjectSection] let sessionCount: Int let hasRecentSessions: Bool - static let empty = State(filteredSessions: [], filteredSessionIDs: [], availableProviders: [], availableProjects: [], projectGroups: [], sessionCount: 0, hasRecentSessions: false) + let activeCount: Int + let attentionCount: Int + static let empty = State(filteredSessions: [], filteredSessionIDs: [], availableProviders: [], availableProjects: [], projectGroups: [], sessionCount: 0, hasRecentSessions: false, activeCount: 0, attentionCount: 0) } private var state = State.empty @@ -85,6 +87,10 @@ final class ActivityCenterProjection { var projectGroups: [ActivityProjectSection] { state.projectGroups } var sessionCount: Int { state.sessionCount } var hasRecentSessions: Bool { state.hasRecentSessions } + /// Active and waiting counts after every filter except status, so the + /// header chips match the list they produce when clicked. + var activeCount: Int { state.activeCount } + var attentionCount: Int { state.attentionCount } func session(id: String) -> AgentSession? { sessionsByID[id] } func parent(of session: AgentSession) -> AgentSession? { session.parentSessionId.flatMap { sessionsByID[$0] } } @@ -155,21 +161,25 @@ final class ActivityCenterProjection { return lhs.id < rhs.id } let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - let filtered = sessions.filter { session in + let inContext = sessions.filter { session in guard providerFilter == "all" || Self.canonicalProviderRawValue(session.provider.rawValue) == Self.canonicalProviderRawValue(providerFilter), projectFilter == "all" || projectKeyBySessionID[session.id] == projectFilter, dateFilter.includes(session.updatedAt, now: now) else { return false } - let statusMatches = switch statusFilter { + return query.isEmpty || Self.searchText(for: session).localizedCaseInsensitiveContains(query) + } + let activeCount = inContext.filter(\.isActive).count + let attentionCount = inContext.filter { $0.state == .waitingForUser }.count + let filtered = inContext.filter { session in + switch statusFilter { case .all: true case .active: session.isActive case .attention: session.state == .waitingForUser case .completed: session.state == .completed case .failed: session.state == .failed } - return statusMatches && (query.isEmpty || Self.searchText(for: session).localizedCaseInsensitiveContains(query)) }.sorted { $0.updatedAt != $1.updatedAt ? $0.updatedAt > $1.updatedAt : $0.id < $1.id } let projectGroups = Self.makeProjectGroups(matchingSessions: filtered, sessionsByID: sessionsByID, projectKeyBySessionID: projectKeyBySessionID, projectTitles: projectTitles) @@ -181,7 +191,9 @@ final class ActivityCenterProjection { availableProjects: availableProjects, projectGroups: projectGroups, sessionCount: sessions.count, - hasRecentSessions: sessions.contains { !$0.isActive } + hasRecentSessions: sessions.contains { !$0.isActive }, + activeCount: activeCount, + attentionCount: attentionCount ) if next != state { state = next } } diff --git a/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterView.swift b/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterView.swift index 1a0e2f4..550a1b0 100644 --- a/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterView.swift +++ b/Sources/AgentsNotch/UI/ActivityCenter/ActivityCenterView.swift @@ -25,8 +25,8 @@ struct ActivityCenterView: View { VStack(spacing: 0) { ActivityCenterHeader( sessionCount: projection.sessionCount, - activeCount: runtime.activity.notchSnapshot.activeSessions.count, - attentionCount: runtime.activity.notchSnapshot.attentionCount, + activeCount: projection.activeCount, + attentionCount: projection.attentionCount, statusFilter: statusFilterBinding, groupingMode: groupingModeBinding, canClearHistory: projection.hasRecentSessions, diff --git a/Tests/AgentsNotchTests/ActivityCenterProjectionTests.swift b/Tests/AgentsNotchTests/ActivityCenterProjectionTests.swift index f690528..d9448a1 100644 --- a/Tests/AgentsNotchTests/ActivityCenterProjectionTests.swift +++ b/Tests/AgentsNotchTests/ActivityCenterProjectionTests.swift @@ -393,6 +393,65 @@ final class ActivityCenterProjectionTests: XCTestCase { XCTAssertNil(projection.groupID(containing: child.id)) } + func testStatusMetricCountsFollowEveryFilterExceptStatus() { + let now = Date(timeIntervalSince1970: 80_000) + let claudeRunning = makeSession( + id: "claude-code:running", + task: "Claude running", + timestamp: now, + directory: "/tmp/Claude", + provider: .claudeCode, + state: .running + ) + let claudeWaiting = makeSession( + id: "claude-code:waiting", + task: "Claude waiting", + timestamp: now, + directory: "/tmp/Claude", + provider: .claudeCode, + state: .waitingForUser + ) + let claudeCompleted = makeSession( + id: "claude-code:done", + task: "Claude done", + timestamp: now, + directory: "/tmp/Claude", + provider: .claudeCode, + state: .completed + ) + let codexRunning = makeSession( + id: "codex:running", + task: "Codex running", + timestamp: now, + directory: "/tmp/Codex", + state: .running + ) + let projection = ActivityCenterProjection() + + projection.update( + sessions: [claudeRunning, claudeWaiting, claudeCompleted, codexRunning], + searchText: "", + providerFilter: AgentProvider.claudeCode.rawValue, + statusFilter: .completed, + now: now + ) + + XCTAssertEqual(projection.activeCount, 2) + XCTAssertEqual(projection.attentionCount, 1) + XCTAssertEqual(projection.filteredSessions.map(\.id), [claudeCompleted.id]) + + projection.update( + sessions: [claudeRunning, claudeWaiting, claudeCompleted, codexRunning], + searchText: "", + providerFilter: "all", + statusFilter: .all, + now: now + ) + + XCTAssertEqual(projection.activeCount, 3) + XCTAssertEqual(projection.attentionCount, 1) + } + func testWorktreeSessionsUseMainRepositoryProjectTitle() throws { let fixture = try LinkedGitWorktree.make() defer { try? FileManager.default.removeItem(at: fixture.root) } @@ -423,12 +482,13 @@ final class ActivityCenterProjectionTests: XCTestCase { timestamp: Date, directory: String, parentID: String? = nil, + provider: AgentProvider = .codex, state: AgentState = .running ) -> AgentSession { AgentSession(event: AgentEvent( type: .activity, sessionId: id, - provider: .codex, + provider: provider, task: task, activity: "Working", state: state, diff --git a/Tests/AgentsNotchTests/AppInstanceCoordinatorTests.swift b/Tests/AgentsNotchTests/AppInstanceCoordinatorTests.swift index 964317b..0db1323 100644 --- a/Tests/AgentsNotchTests/AppInstanceCoordinatorTests.swift +++ b/Tests/AgentsNotchTests/AppInstanceCoordinatorTests.swift @@ -6,7 +6,7 @@ import XCTest final class AppInstanceCoordinatorTests: XCTestCase { func testRepeatedLaunchHandsOffToOldestExistingInstance() { let recorder = ProcessActivationRecorder() - var activationRequestProcessIdentifiers: [pid_t] = [] + var activationRequestProcessIdentifiers: [pid_t?] = [] let coordinator = AppInstanceCoordinator( currentProcessIdentifier: 30, runningInstances: { @@ -25,7 +25,7 @@ final class AppInstanceCoordinatorTests: XCTestCase { } func testFirstLaunchContinuesWithoutPostingActivationRequest() { - var activationRequestProcessIdentifiers: [pid_t] = [] + var activationRequestProcessIdentifiers: [pid_t?] = [] let coordinator = AppInstanceCoordinator( currentProcessIdentifier: 30, runningInstances: { @@ -42,6 +42,94 @@ final class AppInstanceCoordinatorTests: XCTestCase { XCTAssertTrue(activationRequestProcessIdentifiers.isEmpty) } + func testNewerVisiblePeerIsNotTreatedAsExistingInstance() { + let recorder = ProcessActivationRecorder() + var activationRequestProcessIdentifiers: [pid_t?] = [] + let coordinator = AppInstanceCoordinator( + currentProcessIdentifier: 10, + runningInstances: { + [ + self.instance(10, launchedAt: 10, recorder: recorder), + self.instance(20, launchedAt: 20, recorder: recorder), + ] + }, + postActivationRequest: { activationRequestProcessIdentifiers.append($0) } + ) + + XCTAssertFalse(coordinator.handOffIfNeeded()) + XCTAssertTrue(recorder.processIdentifiers.isEmpty) + XCTAssertTrue(activationRequestProcessIdentifiers.isEmpty) + } + + func testOverlappingLaunchesOnlyTheNewerProcessHandsOff() { + let recorder = ProcessActivationRecorder() + var olderActivationRequests: [pid_t?] = [] + var newerActivationRequests: [pid_t?] = [] + let instances = { + [ + self.instance(10, launchedAt: 10, recorder: recorder), + self.instance(20, launchedAt: 20, recorder: recorder), + ] + } + let older = AppInstanceCoordinator( + currentProcessIdentifier: 10, + runningInstances: instances, + postActivationRequest: { olderActivationRequests.append($0) } + ) + let newer = AppInstanceCoordinator( + currentProcessIdentifier: 20, + runningInstances: instances, + postActivationRequest: { newerActivationRequests.append($0) } + ) + + XCTAssertFalse(older.handOffIfNeeded()) + XCTAssertTrue(newer.handOffIfNeeded()) + XCTAssertEqual(recorder.processIdentifiers, [10]) + XCTAssertTrue(olderActivationRequests.isEmpty) + XCTAssertEqual(newerActivationRequests, [10]) + } + + func testLostOwnershipLockHandsOffEvenToANewerPeer() { + let recorder = ProcessActivationRecorder() + var activationRequestProcessIdentifiers: [pid_t?] = [] + let coordinator = AppInstanceCoordinator( + currentProcessIdentifier: 10, + runningInstances: { + [ + self.instance(10, launchedAt: 10, recorder: recorder), + self.instance(20, launchedAt: 20, recorder: recorder), + ] + }, + postActivationRequest: { activationRequestProcessIdentifiers.append($0) }, + tryAcquireOwnership: { false } + ) + + XCTAssertTrue(coordinator.handOffIfNeeded()) + XCTAssertEqual(recorder.processIdentifiers, [20]) + XCTAssertEqual(activationRequestProcessIdentifiers, [20]) + } + + func testLostOwnershipLockWithoutVisiblePeerStillHandsOff() { + var activationRequestProcessIdentifiers: [pid_t?] = [] + let coordinator = AppInstanceCoordinator( + currentProcessIdentifier: 10, + runningInstances: { + [ + RunningAgentNotchInstance( + processIdentifier: 10, + launchDate: Date(timeIntervalSince1970: 10), + activate: { true } + ) + ] + }, + postActivationRequest: { activationRequestProcessIdentifiers.append($0) }, + tryAcquireOwnership: { false } + ) + + XCTAssertTrue(coordinator.handOffIfNeeded()) + XCTAssertEqual(activationRequestProcessIdentifiers, [nil]) + } + private func instance( _ processIdentifier: pid_t, launchedAt: TimeInterval, From 3e302aeca80db639f22c963e9773721b3197bebb Mon Sep 17 00:00:00 2001 From: Aforno Date: Sat, 29 Aug 2026 18:23:53 +0100 Subject: [PATCH 3/3] refactor(desktop): drop connection-status pills from setup and integrati MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace “N of M connected” with install/remove copy - Keep status in accessibility labels and follow-up text - Remove unused ProviderIntegrationStatusView --- CHANGELOG.md | 5 +-- .../UI/Onboarding/OnboardingView.swift | 18 +++------- .../UI/Settings/IntegrationSettingsPane.swift | 18 +++------- .../UI/Settings/SettingsComponents.swift | 33 ------------------- 4 files changed, 12 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1833749..dc58e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Update checks use the Sparkle appcast instead of opening the GitHub releases page. Automatic checks default on, run at launch and once a day, and never download or install until you ask. -- Setup and Integrations show each provider's connection state. Setup also - reports overall readiness and states when it will finish without a provider. +- Setup and Integrations list each provider with install or remove. Follow-up + instructions and errors stay under the name. Setup finishes as Finish Setup + when an observer is installed, or Finish without Connecting otherwise. - Activity Center's Active and Attention metrics filter the session list. Its status filters now keep completed and failed sessions separate. - Tool lifecycle events with a shared call identifier render as one timeline diff --git a/Sources/AgentsNotch/UI/Onboarding/OnboardingView.swift b/Sources/AgentsNotch/UI/Onboarding/OnboardingView.swift index 23897ae..cb0ccfd 100644 --- a/Sources/AgentsNotch/UI/Onboarding/OnboardingView.swift +++ b/Sources/AgentsNotch/UI/Onboarding/OnboardingView.swift @@ -21,9 +21,6 @@ struct OnboardingView: View { .font(NotchWindowFont.body) .foregroundStyle(NotchWindowPalette.secondaryText) .fixedSize(horizontal: false, vertical: true) - Text(connectionSummary) - .font(NotchWindowFont.footnoteEmphasis) - .foregroundStyle(NotchWindowPalette.secondaryText) } } @@ -84,12 +81,10 @@ struct OnboardingView: View { .frame(width: 26) VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 8) { - Text(integration.provider.displayName) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.white.opacity(0.86)) - ProviderIntegrationStatusView(status: integration.status) - } + Text(integration.provider.displayName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.white.opacity(0.86)) + .accessibilityLabel("\(integration.provider.displayName), \(integration.status.title)") if let instructions = integration.trustInstructions { Text(instructions) @@ -136,9 +131,4 @@ struct OnboardingView: View { ? "Finish Setup" : "Finish without Connecting" } - - private var connectionSummary: String { - let connected = runtime.integrations.filter { $0.status.isConnected }.count - return "\(connected) of \(runtime.integrations.count) connected" - } } diff --git a/Sources/AgentsNotch/UI/Settings/IntegrationSettingsPane.swift b/Sources/AgentsNotch/UI/Settings/IntegrationSettingsPane.swift index 05059d6..bd6cb91 100644 --- a/Sources/AgentsNotch/UI/Settings/IntegrationSettingsPane.swift +++ b/Sources/AgentsNotch/UI/Settings/IntegrationSettingsPane.swift @@ -8,7 +8,7 @@ struct IntegrationSettingsPane: View { VStack(alignment: .leading, spacing: NotchWindowMetrics.sectionSpacing) { SettingsHeading( title: "Integrations", - detail: connectionSummary + detail: "Install a local observer for each agent you use." ) runtimeHealthMessages VStack(spacing: 0) { @@ -48,12 +48,10 @@ struct IntegrationSettingsPane: View { ProviderIconView(provider: integration.provider, size: 20) .frame(width: 24, height: 24) VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 8) { - Text(integration.provider.displayName) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.white.opacity(0.86)) - ProviderIntegrationStatusView(status: integration.status) - } + Text(integration.provider.displayName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.white.opacity(0.86)) + .accessibilityLabel("\(integration.provider.displayName), \(integration.status.title)") if let instructions = integration.trustInstructions { Text(instructions) .font(NotchWindowFont.caption) @@ -90,10 +88,4 @@ struct IntegrationSettingsPane: View { .controlSize(.small) .padding(.vertical, 9) } - - private var connectionSummary: String { - let connected = runtime.integrations.filter { $0.status.isConnected }.count - let total = runtime.integrations.count - return "\(connected) of \(total) connected. Observers stay local to this Mac." - } } diff --git a/Sources/AgentsNotch/UI/Settings/SettingsComponents.swift b/Sources/AgentsNotch/UI/Settings/SettingsComponents.swift index 82c6988..f441502 100644 --- a/Sources/AgentsNotch/UI/Settings/SettingsComponents.swift +++ b/Sources/AgentsNotch/UI/Settings/SettingsComponents.swift @@ -320,39 +320,6 @@ struct SettingsMessage: View { } } -/// Compact provider health shared by Setup and Integrations. -struct ProviderIntegrationStatusView: View { - let status: ProviderIntegrationStatus - - var body: some View { - HStack(spacing: 5) { - Circle() - .fill(color) - .frame(width: 6, height: 6) - .accessibilityHidden(true) - Text(status.title) - .lineLimit(1) - } - .font(NotchWindowFont.footnoteEmphasis) - .foregroundStyle(color) - .accessibilityElement(children: .combine) - .accessibilityLabel("Integration status: \(status.title)") - } - - private var color: Color { - switch status { - case .notInstalled: - NotchWindowPalette.tertiaryText - case .awaitingFirstEvent: - .orange - case .connected: - .green - case .unavailable: - .red - } - } -} - struct RuntimeHealthMessages: View { let socketError: String? let persistenceError: String?