Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 40 additions & 54 deletions brain-bar/Sources/BrainBar/BrainBarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ enum BrainBarAppSupport {
static func hotkeyPermissionFailureMessage(permissions: HotkeyPermissionStatus) -> String {
"BrainBar could not start the fallback hotkey listener. Enable \(permissions.missingPermissionsMessage) in System Settings. The CGEventTap fallback requires both Input Monitoring and Accessibility."
}

@MainActor
static func makeStatsCollector(
dbPath: String,
targetPID: pid_t,
brainBusEvents: BrainBusEventSource? = BrainBusClient()
) -> StatsCollector {
StatsCollector(
dbPath: dbPath,
daemonMonitor: DaemonHealthMonitor(targetPID: targetPID),
brainBusEvents: brainBusEvents
)
}
}

@MainActor
Expand All @@ -15,6 +28,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private static let menuBarWindowAutosaveKey = "NSWindow Frame BrainBarMenuBarExtraWindow"

private var server: BrainBarServer?
private var statusPopoverController: BrainBarStatusPopoverController?
private var legacyStatusItem: NSStatusItem?
private var legacyPopover: NSPopover?
private var collector: StatsCollector?
Expand Down Expand Up @@ -47,11 +61,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

startHotkeyFileWatcher()

if launchMode == .menuBarWindow {
UserDefaults.standard.removeObject(forKey: Self.menuBarWindowAutosaveKey)
dashboardPanel = BrainBarDashboardPanelController(runtime: runtime)
}

let runningInstances = NSRunningApplication.runningApplications(
withBundleIdentifier: Bundle.main.bundleIdentifier ?? "com.brainlayer.BrainBar"
)
Expand All @@ -69,7 +78,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self?.configureQuickCaptureHotkey()
}

if launchMode == .legacyStatusItem {
if launchMode == .menuBarWindow {
statusPopoverController = BrainBarStatusPopoverController(runtime: runtime)
} else if launchMode == .legacyStatusItem {
createLegacyStatusItem()
}

Expand All @@ -88,9 +99,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self.sharedDatabase = database
self.configureQuickCapture(database: database)
self.runtime.install(
collector: self.collector ?? StatsCollector(
collector: self.collector ?? BrainBarAppSupport.makeStatsCollector(
dbPath: dbPath,
daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier),
targetPID: ProcessInfo.processInfo.processIdentifier,
brainBusEvents: BrainBusClient()
),
injectionStore: self.injectionStore,
Expand All @@ -100,9 +111,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

let collector = StatsCollector(
let collector = BrainBarAppSupport.makeStatsCollector(
dbPath: dbPath,
daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier),
targetPID: ProcessInfo.processInfo.processIdentifier,
brainBusEvents: BrainBusClient()
)
let injectionStore = try? InjectionStore(databasePath: dbPath)
Expand All @@ -125,6 +136,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
menuBarWindowObservers.forEach(NotificationCenter.default.removeObserver)
menuBarWindowObservers.removeAll()
statusPopoverController?.stop()
statusPopoverController = nil
menuBarWindowSyncTask?.cancel()
menuBarWindowSyncTask = nil
hotkeyFileWatcher?.cancel()
Expand All @@ -149,7 +162,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

runtime.presentQuickAction(.search)
showMenuBarWindow(nil)
statusPopoverController?.show(nil)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Show popover before delivering quick actions

When Cmd-K/search is invoked while the prewarmed popover is closed and the DB is ready, runtime.presentQuickAction(.search) is published before this show call. Because the hosting view was already loaded, BrainBarWindowRootView can consume and clear the request while its text field still has no window; the async focus attempt then sees nsView.window == nil, and the popover opens without the command bar focused. The capture path has the same ordering, so route the request after the popover is visible or defer focus until the popover window exists.

Useful? React with 👍 / 👎.

}

func showQuickCapturePanel() {
Expand All @@ -159,7 +172,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

runtime.presentQuickAction(.capture)
showMenuBarWindow(nil)
statusPopoverController?.show(nil)
}

private func configureRuntimeCallbacks() {
Expand Down Expand Up @@ -238,6 +251,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return
}

if launchMode == .menuBarWindow, let statusPopoverController {
statusPopoverController.toggle(sender)
return
}

if let dashboardPanel {
dashboardPanel.toggle()
return
Expand Down Expand Up @@ -286,10 +304,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return
}

if let statusPopoverController {
statusPopoverController.show(nil)
return
}

dashboardPanel?.show()
}

private func showMenuBarWindow(_ sender: Any?) {
if launchMode == .menuBarWindow, let statusPopoverController {
statusPopoverController.show(sender)
return
}

if launchMode == .menuBarWindow, let dashboardPanel {
dashboardPanel.show()
return
Expand Down Expand Up @@ -861,26 +889,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@main
struct BrainBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
private let launchMode = BrainBarLaunchMode.resolve()

var body: some Scene {
MenuBarExtra(isInserted: .constant(launchMode == .menuBarWindow)) {
Button("Open Dashboard") {
appDelegate.showDashboardPanel()
}

Button("Search BrainLayer") {
appDelegate.showSearchPanel()
}

Button("Capture Note") {
appDelegate.showQuickCapturePanel()
}
} label: {
BrainBarMenuBarLabel(runtime: appDelegate.runtime)
}
.menuBarExtraStyle(.menu)

Settings {
EmptyView()
}
Expand All @@ -902,27 +912,3 @@ struct BrainBarApp: App {
}
}
}

private struct BrainBarMenuBarLabel: View {
@ObservedObject var runtime: BrainBarRuntime

var body: some View {
if let collector = runtime.collector {
let livePresentation = BrainBarLivePresentation.derive(stats: collector.stats)
HStack(spacing: 6) {
Image(systemName: "brain")
Image(
nsImage: SparklineRenderer.render(
state: collector.state,
values: collector.stats.recentEnrichmentBuckets,
size: NSSize(width: 22, height: 12),
accentColor: livePresentation.accentColor
)
)
.interpolation(.high)
}
} else {
Image(systemName: "brain")
}
}
}
104 changes: 104 additions & 0 deletions brain-bar/Sources/BrainBar/BrainBarStatusPopoverController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import AppKit
import Combine
import SwiftUI

@MainActor
final class BrainBarStatusPopoverController: NSObject {
static let contentSize = NSSize(width: 900, height: 640)

let statusItemForTesting: NSStatusItem
let popoverForTesting: NSPopover

private let runtime: BrainBarRuntime
private var runtimeCancellables: Set<AnyCancellable> = []
private var collectorCancellables: Set<AnyCancellable> = []

init(runtime: BrainBarRuntime) {
self.runtime = runtime
statusItemForTesting = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
popoverForTesting = NSPopover()
super.init()

configureStatusItem()
prewarmPopover()
bindRuntime()
}

func toggle(_ sender: Any?) {
if popoverForTesting.isShown {
popoverForTesting.performClose(sender)
} else {
show(sender)
}
}

func show(_ sender: Any?) {
guard let button = statusItemForTesting.button else { return }
popoverForTesting.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popoverForTesting.contentViewController?.view.window?.makeKey()
}

func close(_ sender: Any?) {
popoverForTesting.performClose(sender)
}

func stop() {
close(nil)
NSStatusBar.system.removeStatusItem(statusItemForTesting)
}

private func configureStatusItem() {
guard let button = statusItemForTesting.button else { return }
button.image = NSImage(systemSymbolName: "brain", accessibilityDescription: "BrainBar")
button.target = self
button.action = #selector(toggleFromStatusItem(_:))
button.toolTip = "BrainBar"
}

private func prewarmPopover() {
popoverForTesting.behavior = .transient
popoverForTesting.contentSize = Self.contentSize

let hosting = NSHostingController(
rootView: BrainBarWindowRootView(runtime: runtime, managesWindowFrame: false)
.frame(width: Self.contentSize.width, height: Self.contentSize.height)
)
_ = hosting.view
popoverForTesting.contentViewController = hosting
}

private func bindRuntime() {
runtime.$collector
.receive(on: RunLoop.main)
.sink { [weak self] collector in
self?.bindCollector(collector)
}
.store(in: &runtimeCancellables)
}

private func bindCollector(_ collector: StatsCollector?) {
collectorCancellables.removeAll()
guard let collector else { return }

Publishers.CombineLatest(collector.$stats, collector.$state)
.receive(on: RunLoop.main)
.sink { [weak self] stats, state in
self?.renderStatusIcon(stats: stats, state: state)
}
.store(in: &collectorCancellables)
}

private func renderStatusIcon(stats: BrainDatabase.DashboardStats, state: PipelineState) {
let livePresentation = BrainBarLivePresentation.derive(stats: stats)
statusItemForTesting.button?.image = SparklineRenderer.render(
state: state,
values: stats.recentEnrichmentBuckets,
size: NSSize(width: 22, height: 12),
accentColor: livePresentation.accentColor
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status item brain icon replaced entirely by sparkline

Medium Severity

The renderStatusIcon method overwrites button.image with only the sparkline, completely losing the brain icon set in configureStatusItem. The removed BrainBarMenuBarLabel displayed both a brain icon AND the sparkline side-by-side via an HStack. Once the collector publishes stats, the status item loses its app identifier and shows only a tiny 22×12 sparkline chart, which is a visual regression for the default .menuBarWindow mode.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 740e90c. Configure here.

}

@objc private func toggleFromStatusItem(_ sender: Any?) {
toggle(sender)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import AppKit
import XCTest
@testable import BrainBar

@MainActor
final class BrainBarStatusPopoverControllerTests: XCTestCase {
func testControllerPrewarmsVariableLengthStatusItemPopover() {
let runtime = BrainBarRuntime(launchMode: .menuBarWindow)
let controller = BrainBarStatusPopoverController(runtime: runtime)
defer { controller.stop() }

XCTAssertEqual(controller.statusItemForTesting.length, NSStatusItem.variableLength)
XCTAssertEqual(controller.popoverForTesting.behavior, NSPopover.Behavior.transient)
XCTAssertNotNil(controller.popoverForTesting.contentViewController)
XCTAssertTrue(controller.popoverForTesting.contentViewController?.isViewLoaded == true)
}

func testAppSupportCollectorFactoryWiresBrainBusEvents() {
let tempDBPath = NSTemporaryDirectory() + "brainbar-status-popover-\(UUID().uuidString).db"
let eventSource = RecordingBrainBusEventSource()
let collector = BrainBarAppSupport.makeStatsCollector(
dbPath: tempDBPath,
targetPID: ProcessInfo.processInfo.processIdentifier,
brainBusEvents: eventSource
)
defer {
collector.stop()
try? FileManager.default.removeItem(atPath: tempDBPath)
try? FileManager.default.removeItem(atPath: tempDBPath + "-wal")
try? FileManager.default.removeItem(atPath: tempDBPath + "-shm")
}

collector.start()

XCTAssertEqual(eventSource.streamRequestCount, 1)
}
}

private final class RecordingBrainBusEventSource: BrainBusEventSource, @unchecked Sendable {
private let lock = NSLock()
private var requests = 0

var streamRequestCount: Int {
lock.withLock { requests }
}

func events() -> AsyncStream<BrainBusEvent> {
lock.withLock {
requests += 1
}
return AsyncStream { _ in }
}
}