Skip to content

Commit 4bdbdc7

Browse files
EtanHeyclaude
andcommitted
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>
1 parent 03d66b8 commit 4bdbdc7

2 files changed

Lines changed: 37 additions & 18 deletions

File tree

brain-bar/Sources/BrainBar/BrainBarCommandBar.swift

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,26 +295,50 @@ private struct CommandBarTrailingHint: View {
295295

296296
struct BrainBarCommandBarResultsOverlay: View {
297297
let viewModel: QuickCaptureViewModel?
298+
/// Command bar is ALWAYS visible in the header, but the results overlay is
299+
/// scoped to the Dashboard tab. When the user switches to Injections or
300+
/// Graph, the overlay hides so it doesn't float over unrelated content.
301+
let isOnActiveTab: Bool
298302

299303
var body: some View {
300304
if let viewModel {
301-
BrainBarCommandBarResultsOverlayGate(viewModel: viewModel)
305+
BrainBarCommandBarResultsOverlayGate(
306+
viewModel: viewModel,
307+
isOnActiveTab: isOnActiveTab
308+
)
302309
}
303310
}
304311
}
305312

306313
private struct BrainBarCommandBarResultsOverlayGate: View {
307314
@ObservedObject var viewModel: QuickCaptureViewModel
315+
let isOnActiveTab: Bool
308316

309317
var body: some View {
310-
if shouldShow {
311-
BrainBarCommandBarResultsOverlayReady(viewModel: viewModel)
312-
.transition(.opacity.combined(with: .move(edge: .top)))
318+
ZStack(alignment: .top) {
319+
if shouldShow {
320+
// Full-area transparent tap-catcher UNDER the results card.
321+
// Clicks outside the card dismiss the overlay without clearing
322+
// the typed query (the dismissed flag resets on next keystroke).
323+
Color.clear
324+
.contentShape(Rectangle())
325+
.onTapGesture {
326+
viewModel.dismissSearchOverlay()
327+
}
328+
.frame(maxWidth: .infinity, maxHeight: .infinity)
329+
330+
BrainBarCommandBarResultsOverlayReady(viewModel: viewModel)
331+
.padding(.horizontal, 20)
332+
.padding(.top, 10)
333+
.transition(.opacity.combined(with: .move(edge: .top)))
334+
}
313335
}
336+
.animation(.easeInOut(duration: 0.18), value: shouldShow)
314337
}
315338

316339
private var shouldShow: Bool {
317-
guard viewModel.mode == .search else { return false }
340+
guard isOnActiveTab else { return false }
341+
guard viewModel.mode == .search, !viewModel.isSearchOverlayDismissed else { return false }
318342
return !viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
319343
}
320344
}

brain-bar/Sources/BrainBar/BrainBarWindowRootView.swift

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,14 @@ struct BrainBarWindowRootView: View {
3838
}
3939
}
4040
.frame(maxWidth: .infinity, maxHeight: .infinity)
41-
.contentShape(Rectangle())
42-
.onTapGesture {
43-
// Click-outside-the-overlay: hide results without clearing the query.
44-
commandBarViewModel?.dismissSearchOverlay()
45-
}
46-
.overlay(alignment: .top) {
47-
BrainBarCommandBarResultsOverlay(viewModel: commandBarViewModel)
48-
.padding(.horizontal, 20)
49-
.padding(.top, 10)
50-
.animation(.easeInOut(duration: 0.18), value: commandBarViewModel?.results.count)
51-
.animation(.easeInOut(duration: 0.18), value: commandBarViewModel?.mode)
52-
.animation(.easeInOut(duration: 0.18), value: commandBarViewModel?.inputText.isEmpty)
53-
.animation(.easeInOut(duration: 0.18), value: commandBarViewModel?.isSearchOverlayDismissed)
41+
.overlay {
42+
// Overlay carries its own full-area tap-catcher and only
43+
// renders when the user is on the Dashboard tab with a
44+
// non-empty search query that hasn't been dismissed.
45+
BrainBarCommandBarResultsOverlay(
46+
viewModel: commandBarViewModel,
47+
isOnActiveTab: selectedTab == .dashboard
48+
)
5449
}
5550
}
5651
.frame(

0 commit comments

Comments
 (0)