Fix menubar stuck loading after sleep, double-click on pill tabs, oversized disconnected tabs

Sleep: track forceRefreshTask and cancel it on willSleep alongside
refreshLoopTask. Reset loadingCount on wake so orphaned fetches from
before sleep cannot leave the loading bar stuck.

Tabs: replace Button wrapper with onTapGesture on AgentTab so the
NSPopover hover tooltip does not eat the first click. Add
clickDismissed guard to prevent the popover from re-showing while
the mouse is still over the tab after a tap.

Tab size: only render the quota bar slot when quota data exists
(connected), not for every provider that supports quota. Disconnected
Claude/Codex tabs are now the same height as other tabs.
This commit is contained in:
iamtoruk 2026-05-08 13:00:35 -07:00
parent e22cd158a8
commit b317009181
3 changed files with 32 additions and 36 deletions

View file

@ -118,6 +118,11 @@ final class AppStore {
private var inFlightKeys: Set<PayloadCacheKey> = []
func resetLoadingState() {
loadingCount = 0
inFlightKeys.removeAll()
}
private func invalidateStaleDayCache() {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"

View file

@ -35,6 +35,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var backgroundActivity: NSObjectProtocol?
private var pendingRefreshWork: DispatchWorkItem?
private var refreshLoopTask: Task<Void, Never>?
private var forceRefreshTask: Task<Void, Never>?
func applicationWillFinishLaunching(_ notification: Notification) {
// Set accessory policy before the app's focus chain forms. On macOS Tahoe
@ -87,6 +88,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.forceRefreshTask?.cancel()
self?.forceRefreshTask = nil
self?.refreshLoopTask?.cancel()
self?.refreshLoopTask = nil
}
@ -102,6 +105,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.store.resetLoadingState()
self?.forceRefresh()
if self?.refreshLoopTask == nil { self?.startRefreshLoop() }
}
@ -209,17 +213,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
guard now.timeIntervalSince(lastRefreshTime) > 5 else { return }
lastRefreshTime = now
// Note: do NOT call store.invalidateCache() here. The day-rollover guard
// inside refresh() already wipes the cache when the calendar date has
// changed; wiping unconditionally on every wake/manual-refresh empties
// todayPayload, makes showAgentTabs go false, and triggers the
// full-popover loading overlay (because cache[key] == nil after wipe).
// That's the regression chain documented in the multi-agent review.
//
// showLoading: true is fine now that the overlay condition is
// `!hasCachedData`: it lights up the refresh-button spinner glyph
// without covering the popover body.
Task {
forceRefreshTask?.cancel()
forceRefreshTask = Task {
async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true)
async let today: Void = store.refreshQuietly(period: .today)
_ = await (main, today)

View file

@ -7,17 +7,14 @@ struct AgentTabStrip: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 5) {
ForEach(visibleFilters) { filter in
Button {
AgentTab(
filter: filter,
cost: cost(for: filter),
isActive: store.selectedProvider == filter,
quota: store.quotaSummary(for: filter)
) {
store.switchTo(provider: filter)
} label: {
AgentTab(
filter: filter,
cost: cost(for: filter),
isActive: store.selectedProvider == filter,
quota: store.quotaSummary(for: filter)
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
@ -65,10 +62,12 @@ private struct AgentTab: View {
let cost: Double?
let isActive: Bool
let quota: QuotaSummary?
let onTap: () -> Void
@State private var hoverPopoverShown = false
@State private var hoverEnterTask: DispatchWorkItem?
@State private var hoverExitTask: DispatchWorkItem?
@State private var clickDismissed = false
/// Providers whose AgentTab chip reserves a 3pt bar slot underneath the
/// label, even when not yet connected. Driven by which providers we
@ -93,16 +92,9 @@ private struct AgentTab: View {
.tracking(-0.2)
}
}
// Reserve the bar slot only for providers whose quota source we
// implement (Claude, Codex). Providers that will never have a bar
// (All / Cursor / Droid / Gemini / Copilot) skip the slot entirely
// so the text centers naturally and the chip stays compact.
// Reserving the slot for Claude/Codex prevents the strip from
// jumping by 6pt the moment the user clicks Connect.
if Self.providerSupportsQuota(filter) {
if quota != nil {
AgentTabQuotaBar(quota: quota, isActive: isActive)
.frame(height: 3)
.opacity(quota == nil ? 0 : 1)
}
}
.padding(.horizontal, 10)
@ -113,20 +105,24 @@ private struct AgentTab: View {
)
.foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
.contentShape(Rectangle())
.onTapGesture {
hoverPopoverShown = false
hoverEnterTask?.cancel()
clickDismissed = true
onTap()
}
.onHover { hovering in
// Debounce: 250ms enter so swiping across chips doesn't pop a
// popover for every chip touched, and 150ms exit so cursor travel
// between chip and popover doesn't dismiss prematurely.
hoverEnterTask?.cancel()
hoverExitTask?.cancel()
if hovering, quota != nil {
let task = DispatchWorkItem { hoverPopoverShown = true }
hoverEnterTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: task)
} else {
if !hovering {
clickDismissed = false
let task = DispatchWorkItem { hoverPopoverShown = false }
hoverExitTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: task)
} else if !clickDismissed, quota != nil {
let task = DispatchWorkItem { hoverPopoverShown = true }
hoverEnterTask = task
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: task)
}
}
.popover(isPresented: $hoverPopoverShown) {