From 916c1d16c962dc6ab03f848bf90a1ae37ef4f6e6 Mon Sep 17 00:00:00 2001 From: Elijah Friedman Date: Fri, 19 Jun 2026 13:28:37 -0400 Subject: [PATCH 1/5] fix: align menu items and convert action rows to native NSMenuItems --- Sources/CodexBar/MenuDescriptor.swift | 7 +- .../StatusItemController+Actions.swift | 8 +- .../StatusItemController+CostMenuCard.swift | 2 +- .../CodexBar/StatusItemController+Menu.swift | 67 +------ ...atusItemController+MenuActionMapping.swift | 2 +- ...tatusItemController+MenuPresentation.swift | 167 ----------------- .../StatusItemController+MenuReconcile.swift | 3 + ...ItemController+PersistentMenuActions.swift | 68 +------ ...tatusItemController+UsageHistoryMenu.swift | 4 +- Sources/CodexBar/StatusItemController.swift | 9 +- ...StatusMenuMergedOverviewRefreshTests.swift | 8 +- .../StatusMenuPersistentRefreshTests.swift | 173 +++++------------- 12 files changed, 81 insertions(+), 437 deletions(-) diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index a6244ba71e..70e656fb5a 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -29,6 +29,7 @@ struct MenuDescriptor { } enum MenuActionSystemImage: String { + case installUpdate = "arrow.down.circle" case refresh = "arrow.clockwise" case dashboard = "chart.bar" case statusPage = "waveform.path.ecg" @@ -760,8 +761,10 @@ private enum AccountFormatter { extension MenuDescriptor.MenuAction { var systemImageName: String? { switch self { - case .installUpdate, .settings, .about, .quit: - nil + case .installUpdate: MenuDescriptor.MenuActionSystemImage.installUpdate.rawValue + case .settings: MenuDescriptor.MenuActionSystemImage.settings.rawValue + case .about: MenuDescriptor.MenuActionSystemImage.about.rawValue + case .quit: MenuDescriptor.MenuActionSystemImage.quit.rawValue case .refresh: MenuDescriptor.MenuActionSystemImage.refresh.rawValue case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 61599a9c93..cb4653c3f4 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -89,6 +89,10 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { self.startManualRefresh(for: nil) } + @objc func refreshMenuItem(_ sender: NSMenuItem) { + self.refreshMenuProviderNow(in: sender.menu) + } + func refreshMenuProviderNow(in menu: NSMenu?) { guard let provider = self.manualRefreshProvider(for: menu) else { self.startManualRefresh(for: nil) @@ -125,7 +129,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { self.manualRefreshTask = nil self.manualRefreshProvider = nil self.menuCardRefreshMonitor.endManualRefresh() - self.updatePersistentRefreshRowsInProgress() + self.updatePersistentRefreshItemsEnabled() self.prepareAttachedClosedMenusIfNeeded() } guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } @@ -151,7 +155,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { self.manualRefreshProvider = provider self.manualRefreshTask = task self.menuCardRefreshMonitor.beginManualRefresh(frozenModels: frozenModels, provider: provider) - self.updatePersistentRefreshRowsInProgress() + self.updatePersistentRefreshItemsEnabled() } private func manualRefreshProvider(for menu: NSMenu?) -> UsageProvider? { diff --git a/Sources/CodexBar/StatusItemController+CostMenuCard.swift b/Sources/CodexBar/StatusItemController+CostMenuCard.swift index d397e6930c..270b3808b5 100644 --- a/Sources/CodexBar/StatusItemController+CostMenuCard.swift +++ b/Sources/CodexBar/StatusItemController+CostMenuCard.swift @@ -20,7 +20,7 @@ private struct CostMenuCardRowView: View { .truncationMode(.tail) } } - .padding(.leading, 14) + .padding(.leading, 20) .padding(.trailing, 28) .padding(.vertical, 6) .frame(width: self.width, alignment: .leading) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 7d6057451f..ce4a0c5924 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -796,15 +796,6 @@ extension StatusItemController { menu.addItem(item) case let .action(title, action): let localizedTitle = L(title) - if self.usesPersistentMenuActionItem(for: action) { - menu.addItem(self.makePersistentMenuActionItem( - title: localizedTitle, - action: action, - menu: captureMenu ?? menu, - width: width)) - continue - } - let (selector, represented) = self.selector(for: action) let item = NSMenuItem(title: localizedTitle, action: selector, keyEquivalent: "") item.target = self @@ -831,6 +822,11 @@ extension StatusItemController { item.isEnabled = false self.applySubtitle(subtitle, to: item, title: localizedTitle) } + if action == .refresh { + let targetMenu = captureMenu ?? menu + item.isEnabled = !self.isRefreshActionInFlight(for: targetMenu) + self.persistentRefreshItems.add(item) + } menu.addItem(item) case let .submenu(title, systemImageName, submenuItems): let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") @@ -867,59 +863,6 @@ extension StatusItemController { } } - private func makePersistentMenuActionItem( - title: String, - action: MenuDescriptor.MenuAction, - menu: NSMenu, - width: CGFloat) -> NSMenuItem - { - let shortcut = self.shortcut(for: action) - let row = PersistentMenuActionItemView( - title: title, - systemImageName: self.persistentMenuActionSystemImageName(for: action), - shortcutText: shortcut.map { self.shortcutLabel(for: $0) }, - width: width, - onClick: { [weak self, weak menu] in - self?.performPersistentMenuAction(action, in: menu) - }) - - if action == .refresh { - row.setInProgress(self.isRefreshActionInFlight(for: menu)) - self.persistentRefreshRows.add(row) - } - - let item = NSMenuItem(title: title, action: nil, keyEquivalent: shortcut?.key ?? "") - item.keyEquivalentModifierMask = shortcut?.modifiers ?? NSEvent.ModifierFlags() - item.isEnabled = true - item.view = row - item.toolTip = title - if action != .refresh { - let (selector, represented) = self.selector(for: action) - item.action = selector - item.target = self - item.representedObject = represented - } - return item - } - - private func shortcutLabel(for shortcut: (key: String, modifiers: NSEvent.ModifierFlags)) -> String { - var label = "" - if shortcut.modifiers.contains(.control) { - label += "^" - } - if shortcut.modifiers.contains(.option) { - label += "⌥" - } - if shortcut.modifiers.contains(.shift) { - label += "⇧" - } - if shortcut.modifiers.contains(.command) { - label += "⌘" - } - label += shortcut.key.uppercased() - return label - } - private func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem { let item = NSMenuItem(title: "", action: nil, keyEquivalent: "") let view = self.makeWrappedSecondaryTextView(text: text) diff --git a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift index c4becbc97d..f889207928 100644 --- a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift +++ b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift @@ -4,7 +4,7 @@ extension StatusItemController { func selector(for action: MenuDescriptor.MenuAction) -> (Selector, Any?) { switch action { case .installUpdate: (#selector(self.installUpdate), nil) - case .refresh: (#selector(self.refreshNow), nil) + case .refresh: (#selector(self.refreshMenuItem(_:)), nil) case .refreshAugmentSession: (#selector(self.refreshAugmentSession), nil) case .dashboard: (#selector(self.openDashboard), nil) case .statusPage: (#selector(self.openStatusPage), nil) diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index e16854cc4f..094a9e9921 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -229,170 +229,3 @@ struct MenuCardSectionContainerView: View { } } } - -@MainActor -final class PersistentMenuActionItemView: NSView, MenuCardHighlighting { - static let rowHeight: CGFloat = 28 - - private let backgroundView = NSView() - private let imageView = NSImageView() - private let progressIndicator = NSProgressIndicator() - private let titleField: NSTextField - private let shortcutField: NSTextField - private let onClick: () -> Void - private var highlighted = false - private var isInProgress = false - - override var intrinsicContentSize: NSSize { - NSSize(width: self.frame.width > 0 ? self.frame.width : NSView.noIntrinsicMetric, height: Self.rowHeight) - } - - override var fittingSize: NSSize { - NSSize(width: self.frame.width, height: Self.rowHeight) - } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(NSSize(width: newSize.width, height: Self.rowHeight)) - } - - init( - title: String, - systemImageName: String?, - shortcutText: String?, - width: CGFloat, - onClick: @escaping () -> Void) - { - self.titleField = NSTextField(labelWithString: title) - self.shortcutField = NSTextField(labelWithString: shortcutText ?? "") - self.onClick = onClick - super.init(frame: NSRect(origin: .zero, size: NSSize(width: width, height: Self.rowHeight))) - self.setupView(systemImageName: systemImageName) - self.setHighlighted(false) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } - - override func mouseUp(with event: NSEvent) { - guard event.type == .leftMouseUp else { return } - guard !self.isInProgress else { return } - self.onClick() - } - - func setHighlighted(_ highlighted: Bool) { - self.highlighted = highlighted - self.updateVisualState() - } - - /// Gives the persistent Refresh row static, in-place feedback without an animated view inside - /// the vibrant menu, which would continuously trigger WindowServer recompositing. - func setInProgress(_ inProgress: Bool) { - guard self.isInProgress != inProgress else { return } - self.isInProgress = inProgress - self.progressIndicator.stopAnimation(nil) - self.progressIndicator.isHidden = true - self.updateVisualState() - } - - private func updateVisualState() { - let primaryColor = self.highlighted ? NSColor.selectedMenuItemTextColor : NSColor.controlTextColor - let secondaryColor = self.highlighted ? NSColor.selectedMenuItemTextColor : NSColor.secondaryLabelColor - let alpha: CGFloat = self.isInProgress ? 0.62 : 1 - self.backgroundView.isHidden = !self.highlighted - self.titleField.textColor = primaryColor - self.shortcutField.textColor = secondaryColor - self.imageView.contentTintColor = primaryColor - self.titleField.alphaValue = alpha - self.shortcutField.alphaValue = alpha - self.imageView.alphaValue = alpha - } - - #if DEBUG - var isInProgressForTesting: Bool { - self.isInProgress - } - - var isProgressIndicatorHiddenForTesting: Bool { - self.progressIndicator.isHidden - } - #endif - - private func setupView(systemImageName: String?) { - self.backgroundView.wantsLayer = true - self.backgroundView.layer?.cornerRadius = 6 - self.backgroundView.layer?.backgroundColor = NSColor.selectedContentBackgroundColor.cgColor - self.backgroundView.translatesAutoresizingMaskIntoConstraints = false - self.addSubview(self.backgroundView) - - if let systemImageName, - let image = NSImage(systemSymbolName: systemImageName, accessibilityDescription: nil) - { - image.isTemplate = true - image.size = NSSize(width: 16, height: 16) - self.imageView.image = image - } - self.imageView.translatesAutoresizingMaskIntoConstraints = false - - self.progressIndicator.style = .spinning - self.progressIndicator.controlSize = .small - self.progressIndicator.isIndeterminate = true - self.progressIndicator.isDisplayedWhenStopped = false - self.progressIndicator.isHidden = true - self.progressIndicator.translatesAutoresizingMaskIntoConstraints = false - - self.titleField.font = NSFont.menuFont(ofSize: NSFont.systemFontSize) - self.titleField.lineBreakMode = .byTruncatingTail - self.titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - self.titleField.translatesAutoresizingMaskIntoConstraints = false - - self.shortcutField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) - self.shortcutField.alignment = .right - self.shortcutField.lineBreakMode = .byTruncatingTail - self.shortcutField.setContentHuggingPriority(.required, for: .horizontal) - self.shortcutField.setContentCompressionResistancePriority(.required, for: .horizontal) - self.shortcutField.translatesAutoresizingMaskIntoConstraints = false - - let spacer = NSView() - spacer.translatesAutoresizingMaskIntoConstraints = false - spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) - - let stack = NSStackView() - stack.orientation = .horizontal - stack.alignment = .centerY - stack.spacing = 8 - stack.translatesAutoresizingMaskIntoConstraints = false - stack.addArrangedSubview(self.imageView) - stack.addArrangedSubview(self.titleField) - stack.addArrangedSubview(spacer) - stack.addArrangedSubview(self.shortcutField) - self.addSubview(stack) - // The spinner overlaps the icon's fixed slot so toggling it never changes row metrics. - self.addSubview(self.progressIndicator) - - NSLayoutConstraint.activate([ - self.backgroundView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 6), - self.backgroundView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -6), - self.backgroundView.topAnchor.constraint(equalTo: self.topAnchor, constant: 2), - self.backgroundView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -2), - - self.imageView.widthAnchor.constraint(equalToConstant: 18), - self.imageView.heightAnchor.constraint(equalToConstant: 18), - self.shortcutField.widthAnchor.constraint(equalToConstant: 38), - - self.progressIndicator.centerXAnchor.constraint(equalTo: self.imageView.centerXAnchor), - self.progressIndicator.centerYAnchor.constraint(equalTo: self.imageView.centerYAnchor), - self.progressIndicator.widthAnchor.constraint(equalToConstant: 16), - self.progressIndicator.heightAnchor.constraint(equalToConstant: 16), - - stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12), - stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12), - stack.centerYAnchor.constraint(equalTo: self.centerYAnchor), - ]) - } -} diff --git a/Sources/CodexBar/StatusItemController+MenuReconcile.swift b/Sources/CodexBar/StatusItemController+MenuReconcile.swift index d3c1cd05c6..5b975839bc 100644 --- a/Sources/CodexBar/StatusItemController+MenuReconcile.swift +++ b/Sources/CodexBar/StatusItemController+MenuReconcile.swift @@ -207,6 +207,9 @@ extension StatusItemController { if #available(macOS 14.4, *) { liveItem.subtitle = newItem.subtitle } + if liveItem.action == #selector(self.refreshMenuItem(_:)) { + self.persistentRefreshItems.add(liveItem) + } } private func swapMenuItemContents(_ liveItem: NSMenuItem, _ cachedItem: NSMenuItem) { diff --git a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift index aa754fe4be..fd73fe97c7 100644 --- a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift +++ b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift @@ -1,63 +1,11 @@ import AppKit extension StatusItemController { - func usesPersistentMenuActionItem(for action: MenuDescriptor.MenuAction) -> Bool { - switch action { - case .installUpdate, .refresh, .settings, .about, .quit: - true - default: - false - } - } - - func persistentMenuActionSystemImageName(for action: MenuDescriptor.MenuAction) -> String? { - switch action { - case .installUpdate: - "arrow.down.circle" - case .refresh: - MenuDescriptor.MenuActionSystemImage.refresh.rawValue - case .settings: - MenuDescriptor.MenuActionSystemImage.settings.rawValue - case .about: - MenuDescriptor.MenuActionSystemImage.about.rawValue - case .quit: - MenuDescriptor.MenuActionSystemImage.quit.rawValue - default: - action.systemImageName - } - } - - func performPersistentMenuAction(_ action: MenuDescriptor.MenuAction, in menu: NSMenu?) { - switch action { - case .refresh: - self.refreshMenuProviderNow(in: menu) - case .installUpdate: - self.closeMenuForPersistentAction(menu) - self.installUpdate() - case .settings: - self.closeMenuForPersistentAction(menu) - self.showSettingsGeneral() - case .about: - self.closeMenuForPersistentAction(menu) - self.showSettingsAbout() - case .quit: - self.closeMenuForPersistentAction(menu) - self.quit() - default: - break - } - } - - /// Syncs every live persistent Refresh row's static progress state to the refresh lifecycle. This is - /// an in-place AppKit mutation on the existing row views — it never rebuilds the menu, so it - /// is safe to call during NSMenu tracking. - func updatePersistentRefreshRowsInProgress() { - for row in self.persistentRefreshRows.allObjects { - guard let menu = row.enclosingMenuItem?.menu else { - row.setInProgress(self.manualRefreshTask != nil || self.store.isRefreshing) - continue - } - row.setInProgress(self.isRefreshActionInFlight(for: menu)) + /// Updates native Refresh rows in place while their menus are tracking. + func updatePersistentRefreshItemsEnabled() { + for item in self.persistentRefreshItems.allObjects { + guard let menu = item.menu else { continue } + item.isEnabled = !self.isRefreshActionInFlight(for: menu) } } @@ -84,10 +32,4 @@ extension StatusItemController { maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) return !providers.isEmpty && self.settings.mergedMenuLastSelectedWasOverview } - - private func closeMenuForPersistentAction(_ menu: NSMenu?) { - guard let menu else { return } - menu.cancelTrackingWithoutAnimation() - self.forgetClosedMenu(menu) - } } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index ae27c82f11..5132976641 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -18,9 +18,9 @@ extension StatusItemController { .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 14) + .padding(.leading, 20) .padding(.trailing, 28) - .padding(.vertical, 8) + .padding(.vertical, 6) }, id: "usageHistorySubmenu", width: width, diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index cbf529c064..62dec9d223 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -126,9 +126,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var menuSession = MenuSessionCoordinator() var menuReadinessSignatures: [ObjectIdentifier: String] = [:] let hostedSubviewRenderSignatures = NSMapTable.weakToStrongObjects() - /// Live persistent Refresh rows, tracked weakly so they can be given in-place in-flight - /// feedback (spinner) while the menu stays open, without rebuilding the menu. - let persistentRefreshRows = NSHashTable.weakObjects() + /// Native Refresh items are weakly tracked so their enabled state can change during menu tracking. + let persistentRefreshItems = NSHashTable.weakObjects() var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] var measuredStandardMenuWidthCache: [String: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" @@ -511,9 +510,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin func handleObservedStoreMenuChange() { self.observeStoreChanges() - // In-place spinner sync for the persistent Refresh rows. Safe during menu tracking: - // it mutates existing row views only and never rebuilds the menu. - self.updatePersistentRefreshRowsInProgress() + self.updatePersistentRefreshItemsEnabled() let rootOpenHandledReadiness = self.consumeRootOpenHandledMenuObservationIfNeeded() // `refreshOpenMenus` is only consulted when a menu is currently open. // Computing the readiness signature serializes every enabled provider's diff --git a/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift index 243e199a59..51804117b8 100644 --- a/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift @@ -27,15 +27,15 @@ struct StatusMenuMergedOverviewRefreshTests { #expect(!visibleProviders.contains(.opencode)) controller.store.refreshingProviders.insert(.opencode) - controller.updatePersistentRefreshRowsInProgress() + controller.updatePersistentRefreshItemsEnabled() #expect(controller.isRefreshActionInFlight(for: menu)) let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) - let row = try #require(refreshItem.view as? PersistentMenuActionItemView) - #expect(row.isInProgressForTesting) + #expect(refreshItem.view == nil) + #expect(!refreshItem.isEnabled) var requestCount = 0 controller._test_manualRefreshOperation = { requestCount += 1 } - controller.performPersistentMenuAction(.refresh, in: menu) + controller.refreshMenuItem(refreshItem) #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) for _ in 0..<20 { await Task.yield() diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift index 2b0cf6748c..875fef74f4 100644 --- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -121,7 +121,7 @@ struct StatusMenuPersistentRefreshTests { } @Test - func `refresh menu item is view backed so mouse activation keeps the menu open`() throws { + func `refresh menu item is native so clicking it closes the menu`() throws { let settings = self.makeSettings() settings.refreshFrequency = .manual settings.mergeIcons = false @@ -132,15 +132,15 @@ struct StatusMenuPersistentRefreshTests { controller.menuWillOpen(menu) let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) - #expect(refreshItem.action == nil) - #expect(refreshItem.target == nil) - #expect(refreshItem.view != nil) + #expect(refreshItem.action != nil) + #expect(refreshItem.target === controller) + #expect(refreshItem.view == nil) #expect(refreshItem.keyEquivalent == "r") #expect(refreshItem.keyEquivalentModifierMask == [.command]) } @Test - func `meta menu actions use the same stable row implementation`() throws { + func `persistent action items are native and install update has an icon`() throws { let settings = self.makeSettings() settings.refreshFrequency = .manual settings.mergeIcons = false @@ -149,101 +149,20 @@ struct StatusMenuPersistentRefreshTests { let menu = controller.makeMenu(for: .codex) controller.menuWillOpen(menu) + let updateItem = try #require(menu.items.first { $0.title == "Update ready, restart now?" }) + #expect(MenuDescriptor.MenuAction.installUpdate.systemImageName == "arrow.down.circle") + #expect(updateItem.image != nil) + for title in ["Update ready, restart now?", "Refresh", "Settings...", "About CodexBar", "Quit"] { let item = try #require(menu.items.first { $0.title == title }) - #expect(item.view is PersistentMenuActionItemView) - #expect(item.view?.frame.height == PersistentMenuActionItemView.rowHeight) - if title == "Refresh" { - #expect(item.action == nil) - #expect(item.target == nil) - } else { - #expect(item.action != nil) - #expect(item.target === controller) - } - } - } - - @Test - func `refresh menu item view keeps fixed metrics while highlighted`() { - let views = [ - PersistentMenuActionItemView( - title: "Refresh", - systemImageName: "arrow.clockwise", - shortcutText: "⌘R", - width: 320, - onClick: {}), - PersistentMenuActionItemView( - title: "Settings...", - systemImageName: "gearshape", - shortcutText: "⌘,", - width: 320, - onClick: {}), - PersistentMenuActionItemView( - title: "About CodexBar", - systemImageName: "info.circle", - shortcutText: nil, - width: 320, - onClick: {}), - PersistentMenuActionItemView( - title: "Quit", - systemImageName: nil, - shortcutText: nil, - width: 320, - onClick: {}), - ] - - for view in views { - self.assertStableMetrics(view) + #expect(item.view == nil, "'\(title)' should be a native NSMenuItem with no custom view") + #expect(item.action != nil) + #expect(item.target === controller) } } - private func assertStableMetrics(_ view: PersistentMenuActionItemView) { - #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) - #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) - #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) - - view.setFrameSize(NSSize(width: 360, height: 44)) - #expect(view.frame.width == 360) - #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) - - view.setHighlighted(true) - #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) - #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) - #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) - - view.setHighlighted(false) - #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) - #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) - #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) - } - - @Test - func `refresh row static in-progress state keeps fixed metrics`() { - let view = PersistentMenuActionItemView( - title: "Refresh", - systemImageName: "arrow.clockwise", - shortcutText: "⌘R", - width: 320, - onClick: {}) - - view.setInProgress(true) - #expect(view.isProgressIndicatorHiddenForTesting) - #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) - #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) - #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) - - view.setHighlighted(true) - #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) - - view.setInProgress(false) - #expect(view.isProgressIndicatorHiddenForTesting) - #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) - #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) - #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) - } - @Test - func `persistent refresh rows reflect store refresh state in place`() { + func `native refresh item reflects scoped global and manual refresh state`() throws { let settings = self.makeSettings() settings.refreshFrequency = .manual settings.mergeIcons = false @@ -252,31 +171,34 @@ struct StatusMenuPersistentRefreshTests { let menu = controller.makeMenu(for: .codex) controller.menuWillOpen(menu) - let refreshItem = menu.items.first { $0.title == "Refresh" } - let row = refreshItem?.view as? PersistentMenuActionItemView - #expect(row != nil) - #expect(controller.persistentRefreshRows.allObjects.contains { $0 === row }) + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(refreshItem.view == nil) + #expect(controller.persistentRefreshItems.allObjects.contains { $0 === refreshItem }) + #expect(refreshItem.isEnabled) + + controller.store.refreshingProviders.insert(.claude) + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + + controller.store.refreshingProviders.insert(.codex) + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + controller.store.refreshingProviders.removeAll() + controller.store.isRefreshing = true + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + controller.store.isRefreshing = false controller.manualRefreshProvider = .claude controller.manualRefreshTask = Task {} - controller.updatePersistentRefreshRowsInProgress() - #expect(row?.isInProgressForTesting == true) - #expect(controller.isRefreshActionInFlight(for: menu)) + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) controller.manualRefreshTask = nil controller.manualRefreshProvider = nil - controller.store.isRefreshing = false - controller.updatePersistentRefreshRowsInProgress() - #expect(row?.isInProgressForTesting == false) - - // An unrelated scoped refresh does not affect this provider's row. - controller.store.refreshingProviders.insert(.claude) - controller.updatePersistentRefreshRowsInProgress() - #expect(row?.isInProgressForTesting == false) - - controller.store.refreshingProviders.insert(.codex) - controller.updatePersistentRefreshRowsInProgress() - #expect(row?.isInProgressForTesting == true) + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) } @Test @@ -762,10 +684,6 @@ struct StatusMenuPersistentRefreshTests { settings.mergeIcons = false let controller = self.makeController(settings: settings) - let menu = controller.makeMenu(for: .codex) - controller.menuWillOpen(menu) - let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) - let row = try #require(refreshItem.view as? PersistentMenuActionItemView) let gate = ManualRefreshGate() var requestCount = 0 @@ -781,14 +699,12 @@ struct StatusMenuPersistentRefreshTests { await Task.yield() #expect(requestCount == 1) - #expect(row.isInProgressForTesting) #expect(controller.menuCardRefreshMonitor.isManualRefreshInFlight) gate.resume() await task.value #expect(controller.manualRefreshTask == nil) - #expect(!row.isInProgressForTesting) #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) } @@ -811,12 +727,14 @@ struct StatusMenuPersistentRefreshTests { requestCount += 1 await mouseGate.wait() } - controller.performPersistentMenuAction(.refresh, in: menu) + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + controller.refreshMenuItem(refreshItem) let mouseTask = try #require(controller.manualRefreshTask) #expect(controller.manualRefreshProvider == .claude) #expect(controller.isRefreshActionInFlight(for: codexMenu)) #expect(controller.isRefreshActionInFlight(for: NSMenu())) - controller.performPersistentMenuAction(.refresh, in: codexMenu) + let codexRefreshItem = try #require(codexMenu.items.first { $0.title == "Refresh" }) + controller.refreshMenuItem(codexRefreshItem) await Task.yield() #expect(requestCount == 1) mouseGate.resume() @@ -850,7 +768,8 @@ struct StatusMenuPersistentRefreshTests { var requestCount = 0 controller._test_manualRefreshOperation = { requestCount += 1 } - controller.performPersistentMenuAction(.refresh, in: menu) + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + controller.refreshMenuItem(refreshItem) #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) for _ in 0..<20 { await Task.yield() @@ -877,7 +796,8 @@ struct StatusMenuPersistentRefreshTests { let overviewGate = ManualRefreshGate() controller._test_manualRefreshOperation = { await overviewGate.wait() } - controller.performPersistentMenuAction(.refresh, in: menu) + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + controller.refreshMenuItem(refreshItem) let overviewTask = try #require(controller.manualRefreshTask) #expect(controller.manualRefreshProvider == nil) overviewGate.resume() @@ -926,7 +846,7 @@ struct StatusMenuPersistentRefreshTests { } @Test - func `failed manual refresh returns row to idle and surfaces error`() async throws { + func `failed manual refresh returns native item to enabled and surfaces error`() async throws { let settings = self.makeSettings() settings.refreshFrequency = .manual settings.mergeIcons = false @@ -935,7 +855,6 @@ struct StatusMenuPersistentRefreshTests { let menu = controller.makeMenu(for: .codex) controller.menuWillOpen(menu) let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) - let row = try #require(refreshItem.view as? PersistentMenuActionItemView) let gate = ManualRefreshGate() controller._test_manualRefreshOperation = { @@ -945,13 +864,13 @@ struct StatusMenuPersistentRefreshTests { controller.refreshNow() let task = try #require(controller.manualRefreshTask) - #expect(row.isInProgressForTesting) + #expect(!refreshItem.isEnabled) gate.resume() await task.value #expect(controller.manualRefreshTask == nil) - #expect(!row.isInProgressForTesting) + #expect(refreshItem.isEnabled) let fallback = MenuCardLiveSubtitle(text: "Fallback", style: .info) #expect(controller.menuCardRefreshMonitor.subtitle(for: .codex, fallback: fallback).style == .error) } From 7ee663ea8acf87d56240cb85ad90c55bfabf6d0a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 19 Jun 2026 14:03:51 -0400 Subject: [PATCH 2/5] docs: credit native menu cleanup --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76818952ba..5db212876a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - LiteLLM: show personal and team spend amounts directly on budget rows while suppressing duplicate budget sections. Thanks @hololee! ### Fixed +- Menu: align cost and utilization rows with provider content and use native bottom action items. Thanks @elijahfriedman! - Cost history: keep chart date labels aligned with their bars and visible without clipping. Thanks @elijahfriedman! - Claude settings: dim and disable Avoid Keychain prompts while global Keychain access is disabled. Thanks @Zihao-Qi! - Linux CLI: read OpenCode Go local SQLite usage in automatic mode and allow Command Code billing with a configured manual cookie. From a36ce0eab994d6d1627702f0012bdf4c113e7f02 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 19 Jun 2026 20:53:40 +0100 Subject: [PATCH 3/5] test: cover native scoped refresh actions --- Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift index 875fef74f4..3fabb47048 100644 --- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -719,7 +719,11 @@ struct StatusMenuPersistentRefreshTests { let menu = try #require(controller.makeMenu(for: .claude) as? StatusItemMenu) let codexMenu = try #require(controller.makeMenu(for: .codex) as? StatusItemMenu) controller.menuWillOpen(menu) - defer { controller.menuDidClose(menu) } + controller.menuWillOpen(codexMenu) + defer { + controller.menuDidClose(codexMenu) + controller.menuDidClose(menu) + } let mouseGate = ManualRefreshGate() var requestCount = 0 From 32bde5304bc06da1a5f94fe7df7803f82f484631 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 19 Jun 2026 20:58:20 +0100 Subject: [PATCH 4/5] fix: keep native refresh state current --- ...StatusItemController+MenuSmartUpdate.swift | 2 + ...ItemController+PersistentMenuActions.swift | 4 ++ ...StatusMenuMergedOverviewRefreshTests.swift | 3 +- .../StatusMenuPersistentRefreshTests.swift | 19 ++++-- .../StatusMenuSwitcherRefreshTests.swift | 66 +++++++++++++++++++ 5 files changed, 89 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift index 93da3e683d..16336dd4c2 100644 --- a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift +++ b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift @@ -49,6 +49,8 @@ extension StatusItemController { menu, fromIndex: contentStartIndex, with: cachedItems) + // Cached items may have changed refresh state while detached from a menu. + self.updatePersistentRefreshItemsEnabled() self.cacheMergedSwitcherContent( displacedItems, in: menu, diff --git a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift index fd73fe97c7..0fa8880a5d 100644 --- a/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift +++ b/Sources/CodexBar/StatusItemController+PersistentMenuActions.swift @@ -4,6 +4,10 @@ extension StatusItemController { /// Updates native Refresh rows in place while their menus are tracking. func updatePersistentRefreshItemsEnabled() { for item in self.persistentRefreshItems.allObjects { + guard item.action == #selector(self.refreshMenuItem(_:)) else { + self.persistentRefreshItems.remove(item) + continue + } guard let menu = item.menu else { continue } item.isEnabled = !self.isRefreshActionInFlight(for: menu) } diff --git a/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift index 51804117b8..9f77699192 100644 --- a/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift @@ -35,7 +35,8 @@ struct StatusMenuMergedOverviewRefreshTests { var requestCount = 0 controller._test_manualRefreshOperation = { requestCount += 1 } - controller.refreshMenuItem(refreshItem) + let refreshAction = try #require(refreshItem.action) + _ = controller.perform(refreshAction, with: refreshItem) #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) for _ in 0..<20 { await Task.yield() diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift index 3fabb47048..6f60a3b8ad 100644 --- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -199,6 +199,13 @@ struct StatusMenuPersistentRefreshTests { controller.manualRefreshProvider = nil controller.updatePersistentRefreshItemsEnabled() #expect(refreshItem.isEnabled) + + refreshItem.action = controller.selector(for: .settings).0 + controller.manualRefreshTask = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + #expect(!controller.persistentRefreshItems.allObjects.contains { $0 === refreshItem }) + controller.manualRefreshTask = nil } @Test @@ -732,13 +739,15 @@ struct StatusMenuPersistentRefreshTests { await mouseGate.wait() } let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) - controller.refreshMenuItem(refreshItem) + let refreshAction = try #require(refreshItem.action) + _ = controller.perform(refreshAction, with: refreshItem) let mouseTask = try #require(controller.manualRefreshTask) #expect(controller.manualRefreshProvider == .claude) #expect(controller.isRefreshActionInFlight(for: codexMenu)) #expect(controller.isRefreshActionInFlight(for: NSMenu())) let codexRefreshItem = try #require(codexMenu.items.first { $0.title == "Refresh" }) - controller.refreshMenuItem(codexRefreshItem) + let codexRefreshAction = try #require(codexRefreshItem.action) + _ = controller.perform(codexRefreshAction, with: codexRefreshItem) await Task.yield() #expect(requestCount == 1) mouseGate.resume() @@ -773,7 +782,8 @@ struct StatusMenuPersistentRefreshTests { controller._test_manualRefreshOperation = { requestCount += 1 } let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) - controller.refreshMenuItem(refreshItem) + let refreshAction = try #require(refreshItem.action) + _ = controller.perform(refreshAction, with: refreshItem) #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) for _ in 0..<20 { await Task.yield() @@ -801,7 +811,8 @@ struct StatusMenuPersistentRefreshTests { let overviewGate = ManualRefreshGate() controller._test_manualRefreshOperation = { await overviewGate.wait() } let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) - controller.refreshMenuItem(refreshItem) + let refreshAction = try #require(refreshItem.action) + _ = controller.perform(refreshAction, with: refreshItem) let overviewTask = try #require(controller.manualRefreshTask) #expect(controller.manualRefreshProvider == nil) overviewGate.resume() diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index 716b52e85f..60742fe50a 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -350,6 +350,72 @@ struct StatusMenuSwitcherRefreshTests { #expect(completed.metrics.isEmpty) } + @Test + func `completed refresh re-enables cached item when switching back`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { + controller.manualRefreshTask?.cancel() + controller.releaseStatusItemsForTesting() + } + + let gate = SwitcherRefreshManualGate() + controller._test_manualRefreshOperation = { await gate.wait() } + defer { controller._test_manualRefreshOperation = nil } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + let initialRefreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + + controller.refreshNow() + let refreshTask = try #require(controller.manualRefreshTask) + #expect(!initialRefreshItem.isEnabled) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + defer { controller._test_openMenuRebuildObserver = nil } + + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + + gate.resume() + await refreshTask.value + #expect(controller.manualRefreshTask == nil) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + + let restoredRefreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(restoredRefreshItem.isEnabled) + } + @Test func `native image menu rows are replaced during reconciliation`() { let settings = Self.makeSettings() From 8f131ed5ff6c999e00021056ae5e51feae4a3717 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 19 Jun 2026 23:17:31 +0100 Subject: [PATCH 5/5] fix: resync cached refresh actions --- ...ontroller+MergedSwitcherContentCache.swift | 3 ++ .../StatusMenuSwitcherRefreshTests.swift | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift index 52fa9b5196..f060a53edf 100644 --- a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift +++ b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift @@ -134,6 +134,9 @@ extension StatusItemController { for item in items { menu.addItem(item) } + // Detached Refresh items cannot observe a completed manual refresh. Recompute only + // after AppKit has restored their menu so provider-scoped busy state is available. + self.updatePersistentRefreshItemsEnabled() return true } } diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift index 60742fe50a..da4ce6c915 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -416,6 +416,60 @@ struct StatusMenuSwitcherRefreshTests { #expect(restoredRefreshItem.isEnabled) } + @Test + func `full cached reattachment resynchronizes detached refresh item`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { + controller.manualRefreshTask?.cancel() + controller.releaseStatusItemsForTesting() + } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let cache = try #require( + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.codex)]) + let refreshItem = try #require(cache.items.first { $0.title == "Refresh" }) + + controller.manualRefreshTask = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + menu.removeAllItems() + #expect(refreshItem.menu == nil) + controller.manualRefreshTask = nil + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + #expect(controller.addCachedMergedSwitcherContent( + for: .provider(.codex), + to: menu, + menuWidth: cache.menuWidth, + codexAccountDisplay: cache.codexAccountDisplay, + tokenAccountDisplay: cache.tokenAccountDisplay)) + #expect(refreshItem.menu === menu) + #expect(refreshItem.isEnabled) + } + @Test func `native image menu rows are replaced during reconciliation`() { let settings = Self.makeSettings()