Skip to content

Commit e8fa626

Browse files
EtanHeyclaude
andauthored
feat: BrainBar → MenuBarExtra(.window) + live-state sparklines + full-width hero (Linear UX overhaul part 1) (#248)
* feat: rewrite BrainBar as a MenuBarExtra window * docs: add BrainBar before-after screenshots * feat(brainbar): integrated command bar replaces header Capture/Search buttons Replaces the clunky inline Capture/Search card that lived beneath the hero (duplicated controls, clipped dashboard) and the short-lived floating QuickCapturePanel detour with a persistent command-bar in the window header. Design: Raycast/Linear pattern. Header has two rows: 1. brand · segmented tab picker · hotkey status 2. command bar (mode pill pair + leading SF Symbol + NSTextField + kbd hint) When search mode has a non-empty query, results appear as a dropdown overlay anchored to the top of the tab-content area via `.overlay(alignment: .top)` — dashboard stays fully reachable underneath. - Deletes BrainBarQuickActionSection (the inline card). - Header no longer carries standalone Capture/Search buttons — the command bar's mode pills are the single source of mode truth. - Hotkey/URL actions still route through runtime.presentQuickAction; BrainBarWindowRootView observes and focuses the command bar in the requested mode (setMode + panelDidAppear -> focusRequestCount). - QuickCapturePanelController / floating panel stays wired only for the legacyStatusItem launch mode. - Fixes the input-padding misalignment by normalising textContainerInset to (0, 8) and placeholder padding to (.top 8, .leading 4) in the retained floating QuickCapturePanelView. Tests: `swift test --filter BrainBar` → 263 passed, 0 failures (+2 new tests documenting showSearchPanel/showQuickCapturePanel -> onSearchRequested/onQuickCaptureRequested callback contract). Visual verification: bash brain-bar/build-app.sh + screenshot /tmp/brainbar-v2-idle.png (command bar visible, dashboard reachable) and /tmp/brainbar-v2-overlay.png (search results floating over hero). Closes the 5 UX complaints on PR #248: 1. Scroll-broken / dashboard clipped -> dashboard unchanged, nothing displaces it. 2. Duplicated Capture/Search rows -> single command bar, no header buttons. 3. Input padding misaligned -> textContainerInset + placeholder padding matched. 4. Header Search always blue / Capture never blue -> header buttons removed; mode state lives in the command bar's accent-tinted pill. 5. "Weird as fuck / unintuitive" -> emergent from 1-4; surface is now a known pattern (command bar). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(brainbar): command-bar fast-follow — confirmation, Cmd+Enter, scroll-into-view, context menu Closes 6 live-test issues from the integrated command bar landing commit aab2891: P0 - **Capture confirmation**: `feedback = .success("Stored in BrainLayer")` now auto-clears back to `.idle` after a configurable delay (default 2s). The trailing hint in the command bar briefly shows the green ✓ Stored state, then returns to the keyboard-shortcut legend. - **Enter-label per mode**: capture mode now reads `⏎ Store · ⌘⏎ Store · ⇥ Search`; search mode reads `⏎ Open · ⌘⏎ Capture · ⇥ Capture`. Both verbs and mode targets are explicit — no more "does enter open?" ambiguity. - **Scroll-selected-into-view**: result list is wrapped in `ScrollViewReader` and `proxy.scrollTo(selectedID, anchor: .center)` fires on every `selectedResultID` change. Arrow-key navigation now keeps the highlighted row in frame regardless of list length. P1 - **Cmd+Enter in the command bar**: the default NSTextField delegate chain does NOT route Cmd+Return to `doCommandBy:`, so Cmd+Enter silently did nothing from the UI even though the view-model contract supports it. New `KeyHandlingCommandBarField: NSTextField` subclass overrides `performKeyEquivalent(with:)`, checks keyCode 36 with `.command` while the field editor is first responder, and routes to `onCommandReturn` → `handleInputReturn(modifiers: [.command])`. - **First-keystroke search lag**: `QuickCaptureViewModel.init` now fires a detached `Task` that runs a throwaway `db.search(query:"warm", limit:1)` to warm the sqlite-vec / FTS5 caches. First real keystroke hits a hot path. - **Right-click context menu on results**: each result row gains `.contextMenu` with Copy excerpt (same as double-click), Copy chunk id, and a metadata footer. More destinations (Open in Graph, Reveal in conversation) will follow when those tab redesigns land. Tests - `testHandleInputReturnCommandEnterInCaptureModeStoresAndPreservesMode` documents the Cmd+Enter in capture-mode contract. - `testFeedbackAutoClearsToIdleAfterSuccessWindow` uses the injected `feedbackAutoClearDelay` to verify the feedback state machine. - `swift test --filter BrainBar` → 265 tests, 0 failures (+2 over previous 263). Deferred (follow-on PR, not in this commit) - User speculation on "search → graph result" integration. - Graph + Injections tab design passes to match the command-bar polish. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(brainbar): command-bar polish — honest hint, URL-action bridge, click-outside, guardrails Closes 9 items from PR #248 reviews (user + chatgpt-codex-connector + cursor[bot]) after the live test of 47ff116. P0 shipping blockers get honest hints, URL actions actually fire in menuBarWindow mode, and the unstructured Task.detached warm-up is gone. P0 - **Search hint was lying**: `⏎ Open` suggested the command bar navigates to the selected result. It doesn't — Return in search mode copies the excerpt to the pasteboard. New label: `⏎ Copy · ⌘⏎ Capture · ⇥ Capture`. - **URL actions silently stuck in menuBarWindow mode**: the legacy guard on `configureQuickCapture` kept `quickCapturePanel` nil in menuBarWindow mode, but `ingestBrainBarURLs` was gating dispatch on that same `quickCapturePanel != nil`, so `brainbar://toggle` / `brainbar://search` queued forever. Replaced the panel-nullability check with a launch-mode-aware `isReadyToHandleBrainBarURL()` — menuBarWindow readiness means `runtime.database` is installed. Pending URLs now flush right after `runtime.install(...)`. Verified live: `brainbar://search` now switches the command bar to search mode instead of being dropped. - **Unstructured `Task.detached { db.search("warm") }` removed**. The warm-up was flagged as leak-risk by the reviewer and correlated with phase2Codex's inconclusive `swift test` runs. Removed; proper async-off-main for `submitSearch` will land in commit 4. P1 - **Bridge-layer test for `KeyHandlingCommandBarField`**. Extracted `handleKeyEquivalent(event:isFieldEditorFirstResponder:)` as a pure routing function so the exact regression site (Cmd+Return dropping through the default delegate chain) is covered by XCTest with a synthesized `NSEvent.keyEvent`. Three tests: Cmd+Return consumed when focused, not consumed when unfocused, plain Return not consumed (delegate chain owns it). - **Stale "detached QuickCapturePanelController" failure messages removed** from `BrainBarWindowStateTests.swift:293/308`. New copy reflects the actual architecture (integrated command bar in menuBarWindow mode, legacy panel in legacyStatusItem mode). - **Click-outside dismiss** for the results overlay. New `QuickCaptureViewModel.isSearchOverlayDismissed` @published flag gated by `BrainBarCommandBarResultsOverlayGate.shouldShow`. Clearing is automatic on the next keystroke (`handleInputChange` resets the flag), so the user's query is preserved and typing re-shows the overlay. P2 - **Defer `handleRequestedQuickAction` clearing** until the command bar's view-model is ready. Previously the flag was cleared even when `runtime.database` was nil, dropping early URL/hotkey requests permanently. Replay happens automatically via `onReceive($database)`. - **`InjectionStore` deinit cleanup**. `CFNotificationCenter` observer is now unregistered on deinit too (not only on `stop()`), closing the crash-on-next-mutation window when a store is released without an explicit teardown. - **Duplicate `.frame(width: 540, height: 360)`** at `QuickCapturePanel.swift:897/904` collapsed to one call. Tests - `swift test --filter BrainBar` → 270 tests, 0 failures (+5 over previous 265: three bridge-layer tests, two overlay-state tests). Deferred to commit 4 (tracked in `collab.md`) - Async-off-main `submitSearch` (the correct fix for first-keystroke lag; intentionally split from this commit because it changes the view-model call contract and requires updating existing tests to await). - Teal-color oscillation hysteresis on the hero accent gradient. - Sparkline latest-value dot pixel-snap centering. - "Show in conversation context" right-click entry (new feature — needs detail drawer). - Enrichment idle-reason copy (needs daemon status probe). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(brainbar): scope results overlay to Dashboard + dismiss on tab-switch + wire click-outside Closes 3 new P0s from the post-03d66b8a live test: 1. **Click-outside didn't dismiss** — root cause: commit 03d66b8 added the `isSearchOverlayDismissed` @published flag to `QuickCaptureViewModel` but `BrainBarCommandBarResultsOverlayGate.shouldShow` never actually read it. The flag was being set but shouldShow returned true anyway, so the overlay stayed visible. The earlier `.onTapGesture { viewModel.dismissSearchOverlay() }` on the tab-content Group was firing correctly — the dismissed flag just wasn't gating anything. Now `shouldShow` checks `!isSearchOverlayDismissed` and the overlay hides. 2. **Results overlay persisted across tab switches** — the overlay was attached to the Group that swaps dashboard/injections/graph content, so it floated above whichever tab was active. Fix: the overlay now takes an `isOnActiveTab: Bool` prop; `BrainBarWindowRootView` passes `selectedTab == .dashboard`. `shouldShow` short-circuits false when off-tab. Belt-and-suspenders: `.onChange(of: selectedTab)` on the root view explicitly calls `dismissSearchOverlay()` when the user leaves Dashboard, so any future re-architecture of the overlay host still clears stale state. 3. **Tap-catcher is now built into the overlay itself** as a full-area `Color.clear` + `.onTapGesture` UNDER the results card (ZStack), instead of an `.onTapGesture` attached to the underlying Group. Previously, clicks on dashboard metric cards could be absorbed by their `.background(RoundedRectangle(...))` shapes before reaching the Group's handler; the new design puts a guaranteed-transparent tap layer behind the card so clicks anywhere outside the card reach `dismissSearchOverlay()`. The tap-catcher only exists while the overlay is visible, so normal tab-content interactions are unaffected at rest. Also migrates the animation definitions from the parent to the gate — the ZStack's `.animation(value: shouldShow)` is the single trigger, replacing the parent-level `.animation(value: ...)` chain that had to guess which @published property was changing. Suspected root cause for user's "input stuck after tab-switch" report: the overlay was holding a visible `Color.clear` tap-shape over the entire tab region even after navigating to Graph/Injections, which could confuse AppKit's first-responder dispatch when the user clicked back into the NSTextField. With the overlay now properly scoped to Dashboard + dismissing on switch, the tap-catcher no longer lingers, and the NSTextField owns its own hit region in the header cleanly. Tests - `swift test --filter BrainBar` → 270 tests, 0 failures (unchanged). The existing `testHandleInputChangeClearsOverlayDismissedFlag` and `testDismissSearchOverlayHidesResultsWithoutClearingInput` now actually exercise a gate that honours the flag. Visual verification - `/tmp/brainbar-v5-search-dashboard.png` — typing "brainlayer" on Dashboard shows the results dropdown. - `/tmp/brainbar-v5-click-outside.png` — click below the results card dismisses the overlay; "brainlayer" remains in the input so the next keystroke re-shows. Deferred to commit 5 (as planned): async-off-main submitSearch, teal hysteresis on hero accent, sparkline dot pixel-snap centering. Deferred to commit 6: expand-in-context detail drawer (Atomic pattern) + enrichment metrics redesign. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(brainbar): re-run search on return to search mode + fade-only overlay transition Two user-reported live-test issues on commit 4bdbdc7: 1. **Stale results after search → capture → search round-trip**. User's repro: type "tests" in search (hits populate), switch to Capture, switch back to Search — overlay says "No matches yet for tests". Delete the "t" — search re-runs and finds hits. Re-add "t" — finds again. So the DB + query work; the bug is that setMode(.search) does NOT re-run submitSearch even though inputText was preserved across the mode round-trip, and setMode(.capture) had cleared the results array. Net: for a couple of beats the overlay lies about a query that already has real hits. Fix: when setMode enters `.search` with a non-empty trimmed inputText, call `submitSearch()` so the results array repopulates before the overlay renders. Added `testReturningToSearchModeReRunsSearchWhenInputPreserved` to lock the round-trip contract. 2. **User requested fade-only on the overlay transition** instead of fade+slide. `.transition(.opacity.combined(with: .move(edge: .top)))` → `.transition(.opacity)`. Same 0.18s `easeInOut` duration via the gate's `.animation(value: shouldShow)`. Also confirms from the same live test: click-outside dismiss (wired in 4bdbdc7) now works as intended — "Oh, now if I click somewhere else, it does remove." Tests: `swift test --filter BrainBar` → 271 tests, 0 failures (+1). Commit 5 scope unchanged (async submitSearch, teal hysteresis, sparkline dot centering, stale-state diagnosis for long-running Injections/KG). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ec0c12b commit e8fa626

30 files changed

Lines changed: 3414 additions & 1257 deletions

brain-bar/Sources/BrainBar/BrainBarApp.swift

Lines changed: 677 additions & 123 deletions
Large diffs are not rendered by default.

brain-bar/Sources/BrainBar/BrainBarCommandBar.swift

Lines changed: 491 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import Foundation
2+
3+
@MainActor
4+
final class BrainBarRuntime: ObservableObject {
5+
let launchMode: BrainBarLaunchMode
6+
let hotkeyStatus = HotkeyRouteStatus()
7+
let windowCoordinator: BrainBarWindowCoordinator
8+
9+
@Published private(set) var collector: StatsCollector?
10+
@Published private(set) var injectionStore: InjectionStore?
11+
@Published private(set) var database: BrainDatabase?
12+
@Published private(set) var requestedQuickAction: BrainBarQuickAction?
13+
14+
var onToggleRequested: (() -> Void)?
15+
var onSearchRequested: (() -> Void)?
16+
var onQuickCaptureRequested: (() -> Void)?
17+
18+
init(
19+
launchMode: BrainBarLaunchMode = BrainBarLaunchMode.resolve(),
20+
windowCoordinator: BrainBarWindowCoordinator = BrainBarWindowCoordinator()
21+
) {
22+
self.launchMode = launchMode
23+
self.windowCoordinator = windowCoordinator
24+
}
25+
26+
func install(
27+
collector: StatsCollector,
28+
injectionStore: InjectionStore?,
29+
database: BrainDatabase
30+
) {
31+
self.collector = collector
32+
self.injectionStore = injectionStore
33+
self.database = database
34+
}
35+
36+
func handleToggleRequest() {
37+
if launchMode == .menuBarWindow, windowCoordinator.toggleVisibility() {
38+
return
39+
}
40+
onToggleRequested?()
41+
}
42+
43+
func showSearchPanel() {
44+
onSearchRequested?()
45+
}
46+
47+
func showQuickCapturePanel() {
48+
onQuickCaptureRequested?()
49+
}
50+
51+
func presentQuickAction(_ action: BrainBarQuickAction) {
52+
requestedQuickAction = action
53+
}
54+
55+
func clearQuickActionRequest() {
56+
requestedQuickAction = nil
57+
}
58+
}

brain-bar/Sources/BrainBar/BrainBarServer.swift

Lines changed: 34 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
// - JSON-RPC router
77
// - SQLite database (single-writer)
88

9-
import Darwin
109
import Foundation
1110

1211
final class BrainBarServer: @unchecked Sendable {
@@ -67,13 +66,6 @@ final class BrainBarServer: @unchecked Sendable {
6766
}
6867
}
6968

70-
private struct PendingWrite {
71-
let data: Data
72-
var totalWritten: Int
73-
var lastProgressAt: UInt64
74-
let onDelivered: (() -> Void)?
75-
}
76-
7769
private let socketPath: String
7870
private let dbPath: String
7971
private let providedDatabase: BrainDatabase?
@@ -84,11 +76,9 @@ final class BrainBarServer: @unchecked Sendable {
8476
private var router: MCPRouter!
8577
private var database: BrainDatabase!
8678
var onDatabaseReady: (@Sendable (BrainDatabase) -> Void)?
87-
/// Max time to wait for a backpressured client to become writable again.
88-
/// Temporary bursts should survive; truly dead peers still get disconnected.
89-
static let writeStallTimeoutMilliseconds: Int32 = 250
90-
static let writeChunkSize = 4_096
91-
static let writeRetrySleepMicroseconds: useconds_t = 2_000
79+
/// Maximum EAGAIN retries before disconnecting a stalled client.
80+
/// Each retry sleeps 1ms, so 10 retries = 10ms max blocking the serial queue.
81+
static let maxWriteRetries = 10
9282
private let debugLogPath = "/tmp/brainbar-debug.log"
9383

9484
private func debugLog(_ msg: String) {
@@ -109,16 +99,14 @@ final class BrainBarServer: @unchecked Sendable {
10999
debugLog("\(label) (\(data.count) bytes)\n HEX: \(hex)\n TEXT: \(text)")
110100
}
111101

112-
private struct ClientState {
102+
struct ClientState {
113103
var source: DispatchSourceRead
114104
var framing: MCPFraming
115105
/// Whether this client uses Content-Length framing (LSP-style).
116106
/// false = newline-delimited JSON-RPC (Claude Code v2.1+).
117107
var usesContentLengthFraming: Bool = true
118108
var agentID: String?
119109
var subscribedTags: Set<String> = []
120-
var pendingWrites: [PendingWrite] = []
121-
var hasScheduledWriteRetry = false
122110
}
123111

124112
init(socketPath: String? = nil, dbPath: String? = nil, database: BrainDatabase? = nil) {
@@ -338,12 +326,7 @@ final class BrainBarServer: @unchecked Sendable {
338326
}
339327

340328
@discardableResult
341-
private func sendResponse(
342-
fd: Int32,
343-
response: [String: Any],
344-
useContentLength: Bool = true,
345-
onDelivered: (() -> Void)? = nil
346-
) -> Bool {
329+
private func sendResponse(fd: Int32, response: [String: Any], useContentLength: Bool = true) -> Bool {
347330
let framed: Data
348331
if useContentLength {
349332
guard let data = try? MCPFraming.encode(response) else { return false }
@@ -355,94 +338,35 @@ final class BrainBarServer: @unchecked Sendable {
355338
data.append(0x0A) // trailing \n
356339
framed = data
357340
}
358-
return enqueueWrite(fd: fd, data: framed, onDelivered: onDelivered)
359-
}
360-
361-
@discardableResult
362-
private func enqueueWrite(fd: Int32, data: Data, onDelivered: (() -> Void)? = nil) -> Bool {
363-
guard var state = clients[fd] else { return false }
364-
state.pendingWrites.append(
365-
PendingWrite(
366-
data: data,
367-
totalWritten: 0,
368-
lastProgressAt: DispatchTime.now().uptimeNanoseconds,
369-
onDelivered: onDelivered
370-
)
371-
)
372-
clients[fd] = state
373-
return flushPendingWrites(fd: fd)
374-
}
375-
376-
@discardableResult
377-
private func flushPendingWrites(fd: Int32) -> Bool {
378-
guard var state = clients[fd] else { return false }
379-
state.hasScheduledWriteRetry = false
380-
clients[fd] = state
381-
382-
while var pending = clients[fd]?.pendingWrites.first {
383-
let remaining = pending.data.count - pending.totalWritten
384-
let nextChunkSize = min(remaining, Self.writeChunkSize)
385-
let n = pending.data.withUnsafeBytes { ptr in
386-
write(fd, ptr.baseAddress!.advanced(by: pending.totalWritten), nextChunkSize)
387-
}
388-
if n < 0 {
389-
if errno == EINTR {
390-
continue
391-
}
392-
if errno == EAGAIN || errno == EWOULDBLOCK {
393-
let now = DispatchTime.now().uptimeNanoseconds
394-
let stallDeadline = pending.lastProgressAt
395-
+ UInt64(Self.writeStallTimeoutMilliseconds) * 1_000_000
396-
guard now < stallDeadline else {
397-
NSLog(
398-
"[BrainBar] ⚠️ Write stalled on fd %d for %d ms — disconnecting dead client",
399-
fd,
400-
Self.writeStallTimeoutMilliseconds
401-
)
402-
disconnectClient(fd: fd)
403-
return false
341+
return framed.withUnsafeBytes { ptr in
342+
var totalWritten = 0
343+
var eagainRetries = 0
344+
while totalWritten < framed.count {
345+
let n = write(fd, ptr.baseAddress!.advanced(by: totalWritten), framed.count - totalWritten)
346+
if n < 0 {
347+
if errno == EAGAIN || errno == EWOULDBLOCK {
348+
eagainRetries += 1
349+
if eagainRetries > Self.maxWriteRetries {
350+
NSLog("[BrainBar] ⚠️ Write stalled on fd %d after %d EAGAIN retries (%d ms) — disconnecting dead client", fd, eagainRetries, eagainRetries - 1)
351+
disconnectClient(fd: fd)
352+
return false
353+
}
354+
usleep(1000) // 1 ms
355+
continue
404356
}
405-
scheduleWriteRetryIfNeeded(fd: fd)
406-
return true
357+
NSLog("[BrainBar] Write error on fd %d: errno %d", fd, errno)
358+
disconnectClient(fd: fd)
359+
return false
407360
}
408-
NSLog("[BrainBar] Write error on fd %d: errno %d", fd, errno)
409-
disconnectClient(fd: fd)
410-
return false
411-
}
412-
if n == 0 {
413-
NSLog("[BrainBar] Write returned 0 on fd %d — peer closed", fd)
414-
disconnectClient(fd: fd)
415-
return false
416-
}
417-
418-
pending.totalWritten += n
419-
pending.lastProgressAt = DispatchTime.now().uptimeNanoseconds
420-
421-
guard var latest = clients[fd] else { return false }
422-
latest.pendingWrites[0] = pending
423-
424-
if pending.totalWritten == pending.data.count {
425-
let onDelivered = pending.onDelivered
426-
latest.pendingWrites.removeFirst()
427-
clients[fd] = latest
428-
onDelivered?()
429-
} else {
430-
clients[fd] = latest
361+
if n == 0 {
362+
NSLog("[BrainBar] Write returned 0 on fd %d — peer closed", fd)
363+
disconnectClient(fd: fd)
364+
return false
365+
}
366+
totalWritten += n
367+
eagainRetries = 0 // reset on successful partial write
431368
}
432-
}
433-
434-
return clients[fd] != nil
435-
}
436-
437-
private func scheduleWriteRetryIfNeeded(fd: Int32) {
438-
guard var state = clients[fd], !state.hasScheduledWriteRetry else { return }
439-
state.hasScheduledWriteRetry = true
440-
clients[fd] = state
441-
442-
let retryDelay = DispatchTimeInterval.microseconds(Int(Self.writeRetrySleepMicroseconds))
443-
queue.asyncAfter(deadline: .now() + retryDelay) { [weak self] in
444-
guard let self, self.clients[fd] != nil else { return }
445-
_ = self.flushPendingWrites(fd: fd)
369+
return true
446370
}
447371
}
448372

@@ -669,13 +593,10 @@ final class BrainBarServer: @unchecked Sendable {
669593
let delivered = sendResponse(
670594
fd: clientFD,
671595
response: notificationObject,
672-
useContentLength: client.usesContentLengthFraming,
673-
onDelivered: { [weak database] in
674-
try? database?.markDelivered(agentID: agentID, seq: stored.rowID)
675-
}
596+
useContentLength: client.usesContentLengthFraming
676597
)
677-
if !delivered {
678-
continue
598+
if delivered {
599+
try? database?.markDelivered(agentID: agentID, seq: stored.rowID)
679600
}
680601
}
681602
}

0 commit comments

Comments
 (0)