From 6e6704488712b09aef68b6e9349a95b766392411 Mon Sep 17 00:00:00 2001 From: Atila Alacan Date: Sat, 4 Jul 2026 08:44:09 +0200 Subject: [PATCH 1/5] feat(agent): add model picker keyboard shortcut - route the remappable shortcut through the focused window to the active composer - add a stable menu open request path with teardown-safe presentation guards - cover shortcut catalog registration and composer open request filtering --- .../GlobalKeyboardShortcutsCoordinator.swift | 10 ++ .../App/Notifications/AppNotifications.swift | 4 + .../AgentMode/Views/AgentInputBar.swift | 54 +++++++++ .../Views/KeyboardShortcutsSettingsView.swift | 3 +- .../UI/Components/StableMenuButton.swift | 18 ++- .../Infrastructure/Utilities/Shortcuts.swift | 2 + ...gentModelPickerOpenRequestGuardTests.swift | 107 ++++++++++++++++++ .../App/KeyboardShortcutCatalogTests.swift | 18 +++ 8 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 Tests/RepoPromptTests/AgentMode/AgentModelPickerOpenRequestGuardTests.swift create mode 100644 Tests/RepoPromptTests/App/KeyboardShortcutCatalogTests.swift diff --git a/Sources/RepoPrompt/App/GlobalKeyboardShortcutsCoordinator.swift b/Sources/RepoPrompt/App/GlobalKeyboardShortcutsCoordinator.swift index 7a632d3f3..75ba89410 100644 --- a/Sources/RepoPrompt/App/GlobalKeyboardShortcutsCoordinator.swift +++ b/Sources/RepoPrompt/App/GlobalKeyboardShortcutsCoordinator.swift @@ -218,6 +218,7 @@ final class GlobalKeyboardShortcutsCoordinator { register(.nextParentAgentSession) { [weak self] in self?.focusAdjacentParentAgentSession(forward: true) } register(.showCurrentWindowAgentNavigationHUD) { [weak self] in self?.showAgentNavigationHUD(mode: .currentWindow) } register(.showAllAgentsNavigationHUD) { [weak self] in self?.showAgentNavigationHUD(mode: .allAgents) } + register(.showAgentModelPicker) { [weak self] in self?.showAgentModelPicker() } } private func startNewAgentSessionFromShortcut() { @@ -274,4 +275,13 @@ final class GlobalKeyboardShortcutsCoordinator { ] ) } + + private func showAgentModelPicker() { + guard let win = guardedFocusedWindowState() else { return } + NotificationCenter.default.post( + name: .showAgentModelPicker, + object: nil, + userInfo: ["windowID": win.windowID] + ) + } } diff --git a/Sources/RepoPrompt/App/Notifications/AppNotifications.swift b/Sources/RepoPrompt/App/Notifications/AppNotifications.swift index 32e830b5d..326638ff0 100644 --- a/Sources/RepoPrompt/App/Notifications/AppNotifications.swift +++ b/Sources/RepoPrompt/App/Notifications/AppNotifications.swift @@ -95,4 +95,8 @@ extension Notification.Name { /// ⌘1–⌘9 handlers before they fall through to regular compose-tab switching. /// userInfo: ["windowID": Int, "resultIndex": Int, "handledRequest": AgentNavigationHUDHandledRequest] static let selectAgentNavigationHUDResult = Notification.Name("selectAgentNavigationHUDResult") + + /// Request that Agent Mode open the Agent/Model picker for the target window. + /// userInfo: ["windowID": Int] + static let showAgentModelPicker = Notification.Name("showAgentModelPicker") } diff --git a/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift b/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift index 1a64e1967..d8813ed67 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift @@ -176,6 +176,47 @@ struct AgentInputBar: View { } } +enum AgentModelPickerOpenRequestGuard { + static func shouldOpen( + notification: Notification, + composerWindowID: Int, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool, + modelControlsDisabled: Bool + ) -> Bool { + shouldOpen( + requestWindowID: windowID(from: notification.userInfo), + composerWindowID: composerWindowID, + composerCurrentTabID: composerCurrentTabID, + propsCurrentTabID: propsCurrentTabID, + hasAvailableAgentProviders: hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled + ) + } + + static func shouldOpen( + requestWindowID: Int?, + composerWindowID: Int, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool, + modelControlsDisabled: Bool + ) -> Bool { + guard requestWindowID == composerWindowID else { return false } + guard let composerCurrentTabID, composerCurrentTabID == propsCurrentTabID else { return false } + guard hasAvailableAgentProviders else { return false } + guard !modelControlsDisabled else { return false } + // Locked provider pickers still open on click to show the lock message; + // shortcut requests intentionally match that behavior. + return true + } + + private static func windowID(from userInfo: [AnyHashable: Any]?) -> Int? { + userInfo?["windowID"] as? Int + } +} + enum AgentFileMentionText { static func attachmentDisplayName(for suggestion: MentionSuggestion) -> String { let trimmedPath = suggestion.relativePath.trimmingCharacters(in: .whitespacesAndNewlines) @@ -266,6 +307,7 @@ struct AgentComposerView: View, Equatable { @State private var showClaudeToolsPopover: Bool = false @State private var steeringUnsupportedMessage: String? = nil @State private var steeringUnsupportedDismissTask: Task? + @State private var modelPickerOpenRequestCount: Int = 0 @State private var modelMenuSnapshotByAgent: [AgentProviderKind: [AgentModelOption]]? = nil @State private var modelMenuSnapshotReleaseTask: Task? = nil @@ -538,6 +580,17 @@ struct AgentComposerView: View, Equatable { showSteeringUnsupportedNotice(event.message) } } + .onReceive(NotificationCenter.default.publisher(for: .showAgentModelPicker)) { notification in + guard AgentModelPickerOpenRequestGuard.shouldOpen( + notification: notification, + composerWindowID: windowID, + composerCurrentTabID: currentTabID, + propsCurrentTabID: props.currentTabID, + hasAvailableAgentProviders: props.hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled + ) else { return } + modelPickerOpenRequestCount += 1 + } .onDrop(of: [UTType.fileURL, UTType.image], isTargeted: $isImageDropTargeted, perform: handleImageDrop(providers:)) .overlay(imageDropOutline) } @@ -804,6 +857,7 @@ struct AgentComposerView: View, Equatable { StableMenuButton( items: agentProviderModelMenuItems, triggerStyle: .plain, + openRequestCount: modelPickerOpenRequestCount, onOpen: captureModelMenuSnapshot ) { HStack(spacing: 4) { diff --git a/Sources/RepoPrompt/Features/Settings/Views/KeyboardShortcutsSettingsView.swift b/Sources/RepoPrompt/Features/Settings/Views/KeyboardShortcutsSettingsView.swift index e92a9b990..d504ff1cc 100644 --- a/Sources/RepoPrompt/Features/Settings/Views/KeyboardShortcutsSettingsView.swift +++ b/Sources/RepoPrompt/Features/Settings/Views/KeyboardShortcutsSettingsView.swift @@ -26,7 +26,8 @@ enum KeyboardShortcutCatalog { name: .toggleContextComposer ), .init(id: "agent-nav-current", title: "Show Agent Session Switcher", detail: "Jump between Agent sessions in the focused window.", name: .showCurrentWindowAgentNavigationHUD), - .init(id: "agent-nav-all", title: "Search all Agent sessions", detail: "Jump to active or recent Agent sessions across windows.", name: .showAllAgentsNavigationHUD) + .init(id: "agent-nav-all", title: "Search all Agent sessions", detail: "Jump to active or recent Agent sessions across windows.", name: .showAllAgentsNavigationHUD), + .init(id: "agent-model-picker", title: "Open Agent/Model picker", detail: "Open the model picker for the focused Agent session.", name: .showAgentModelPicker) ] ), KeyboardShortcutCatalogSection( diff --git a/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift b/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift index fa6d21636..d9f5ff6e7 100644 --- a/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift +++ b/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift @@ -15,6 +15,7 @@ struct StableMenuButton: View { let items: () -> [StableMenuItem] let triggerStyle: TriggerStyle + let openRequestCount: Int let onOpen: @MainActor () -> Void @ViewBuilder let label: () -> Label @@ -23,11 +24,13 @@ struct StableMenuButton: View { init( items: @escaping () -> [StableMenuItem], triggerStyle: TriggerStyle = .automatic, + openRequestCount: Int = 0, onOpen: @escaping @MainActor () -> Void = {}, @ViewBuilder label: @escaping () -> Label ) { self.items = items self.triggerStyle = triggerStyle + self.openRequestCount = openRequestCount self.onOpen = onOpen self.label = label } @@ -45,8 +48,7 @@ struct StableMenuButton: View { private var button: some View { Button { - onOpen() - presenter.present(items()) + presentMenu() } label: { label() } @@ -54,6 +56,16 @@ struct StableMenuButton: View { StableMenuAnchorView(presenter: presenter) .allowsHitTesting(false) ) + .onChange(of: openRequestCount) { + DispatchQueue.main.async { + presentMenu() + } + } + } + + private func presentMenu() { + onOpen() + presenter.present(items()) } } @@ -209,7 +221,7 @@ private final class StableMenuPresenter: NSObject, ObservableObject, NSMenuDeleg func present(_ items: [StableMenuItem]) { guard !items.isEmpty else { return } - guard let anchorView else { return } + guard let anchorView, anchorView.window != nil else { return } retainedMenu?.cancelTracking() let menu = NSMenu.stableMenu(from: items, fontPreset: FontScalePreset.current) diff --git a/Sources/RepoPrompt/Infrastructure/Utilities/Shortcuts.swift b/Sources/RepoPrompt/Infrastructure/Utilities/Shortcuts.swift index 51a053f03..cf8999a39 100644 --- a/Sources/RepoPrompt/Infrastructure/Utilities/Shortcuts.swift +++ b/Sources/RepoPrompt/Infrastructure/Utilities/Shortcuts.swift @@ -46,6 +46,8 @@ extension KeyboardShortcuts.Name { static let showCurrentWindowAgentNavigationHUD = Self("showCurrentWindowAgentNavigationHUD", default: .init(.k, modifiers: [.command])) /// Show the all-active/recent Agents navigation HUD. static let showAllAgentsNavigationHUD = Self("showAllAgentsNavigationHUD", default: .init(.k, modifiers: [.command, .shift])) + /// Open the focused Agent session's Agent/Model picker. + static let showAgentModelPicker = Self("showAgentModelPicker", default: .init(.m, modifiers: [.command, .shift])) /// Cycle to the previous root Agent session row. static let previousParentAgentSession = Self("previousParentAgentSession", default: .init(.leftBracket, modifiers: [.command, .option])) /// Cycle to the next root Agent session row. diff --git a/Tests/RepoPromptTests/AgentMode/AgentModelPickerOpenRequestGuardTests.swift b/Tests/RepoPromptTests/AgentMode/AgentModelPickerOpenRequestGuardTests.swift new file mode 100644 index 000000000..11c7b22b6 --- /dev/null +++ b/Tests/RepoPromptTests/AgentMode/AgentModelPickerOpenRequestGuardTests.swift @@ -0,0 +1,107 @@ +import Foundation +@testable import RepoPromptApp +import XCTest + +final class AgentModelPickerOpenRequestGuardTests: XCTestCase { + func testAllowsMatchingWindowAndCurrentTab() { + let tabID = UUID() + let notification = Notification( + name: .showAgentModelPicker, + object: nil, + userInfo: ["windowID": 42] + ) + + XCTAssertTrue( + AgentModelPickerOpenRequestGuard.shouldOpen( + notification: notification, + composerWindowID: 42, + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + hasAvailableAgentProviders: true, + modelControlsDisabled: false + ) + ) + } + + func testRejectsMismatchedWindow() { + let tabID = UUID() + XCTAssertFalse( + modelPickerShortcutGuardAllows( + requestWindowID: 41, + composerWindowID: 42, + composerCurrentTabID: tabID, + propsCurrentTabID: tabID + ) + ) + } + + func testRejectsMissingWindow() { + let tabID = UUID() + XCTAssertFalse( + modelPickerShortcutGuardAllows( + requestWindowID: nil, + composerCurrentTabID: tabID, + propsCurrentTabID: tabID + ) + ) + } + + func testRejectsMissingComposerTab() { + let tabID = UUID() + XCTAssertFalse( + modelPickerShortcutGuardAllows( + composerCurrentTabID: nil, + propsCurrentTabID: tabID + ) + ) + } + + func testRejectsMismatchedPropsTab() { + XCTAssertFalse( + modelPickerShortcutGuardAllows( + composerCurrentTabID: UUID(), + propsCurrentTabID: UUID() + ) + ) + } + + func testRejectsMissingProviders() { + let tabID = UUID() + XCTAssertFalse( + modelPickerShortcutGuardAllows( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + hasAvailableAgentProviders: false + ) + ) + } + + func testRejectsDisabledModelControls() { + let tabID = UUID() + XCTAssertFalse( + modelPickerShortcutGuardAllows( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + modelControlsDisabled: true + ) + ) + } + + private func modelPickerShortcutGuardAllows( + requestWindowID: Int? = 42, + composerWindowID: Int = 42, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool = true, + modelControlsDisabled: Bool = false + ) -> Bool { + AgentModelPickerOpenRequestGuard.shouldOpen( + requestWindowID: requestWindowID, + composerWindowID: composerWindowID, + composerCurrentTabID: composerCurrentTabID, + propsCurrentTabID: propsCurrentTabID, + hasAvailableAgentProviders: hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled + ) + } +} diff --git a/Tests/RepoPromptTests/App/KeyboardShortcutCatalogTests.swift b/Tests/RepoPromptTests/App/KeyboardShortcutCatalogTests.swift new file mode 100644 index 000000000..c21b90d6f --- /dev/null +++ b/Tests/RepoPromptTests/App/KeyboardShortcutCatalogTests.swift @@ -0,0 +1,18 @@ +import KeyboardShortcuts +@testable import RepoPromptApp +import XCTest + +final class KeyboardShortcutCatalogTests: XCTestCase { + func testAgentLayoutCatalogContainsAgentModelPickerShortcut() throws { + let section = try XCTUnwrap( + KeyboardShortcutCatalog.sections.first(where: { $0.id == "agent-layout" }) + ) + + let binding = try XCTUnwrap( + section.bindings.first(where: { $0.id == "agent-model-picker" }) + ) + + XCTAssertEqual(binding.title, "Open Agent/Model picker") + XCTAssertEqual(binding.name, KeyboardShortcuts.Name.showAgentModelPicker) + } +} From 2fda29e2eae76c87b2374026518034c7c0e46a56 Mon Sep 17 00:00:00 2001 From: Atila Alacan Date: Sun, 5 Jul 2026 02:45:56 +0200 Subject: [PATCH 2/5] feat(agent): add effort picker shortcut - add an unbound global shortcut for opening the active effort picker - route shortcut requests through focused-window composer guards - snapshot Codex effort options into composer props for stable menu state This extends the model picker shortcut flow to provider effort controls while keeping shortcut-triggered menus scoped to the active composer. --- .../GlobalKeyboardShortcutsCoordinator.swift | 10 + .../App/Notifications/AppNotifications.swift | 4 + .../AgentModeViewModel+ComposerUI.swift | 1 + .../ViewModels/UI/AgentComposerUIModels.swift | 2 + .../AgentMode/Views/AgentInputBar.swift | 214 ++++++++++++++---- .../Views/KeyboardShortcutsSettingsView.swift | 3 +- .../Infrastructure/Utilities/Shortcuts.swift | 2 + ...gentModelPickerOpenRequestGuardTests.swift | 203 +++++++++++++++++ .../App/KeyboardShortcutCatalogTests.swift | 22 +- 9 files changed, 408 insertions(+), 53 deletions(-) diff --git a/Sources/RepoPrompt/App/GlobalKeyboardShortcutsCoordinator.swift b/Sources/RepoPrompt/App/GlobalKeyboardShortcutsCoordinator.swift index 75ba89410..078fb96c7 100644 --- a/Sources/RepoPrompt/App/GlobalKeyboardShortcutsCoordinator.swift +++ b/Sources/RepoPrompt/App/GlobalKeyboardShortcutsCoordinator.swift @@ -219,6 +219,7 @@ final class GlobalKeyboardShortcutsCoordinator { register(.showCurrentWindowAgentNavigationHUD) { [weak self] in self?.showAgentNavigationHUD(mode: .currentWindow) } register(.showAllAgentsNavigationHUD) { [weak self] in self?.showAgentNavigationHUD(mode: .allAgents) } register(.showAgentModelPicker) { [weak self] in self?.showAgentModelPicker() } + register(.showAgentEffortPicker) { [weak self] in self?.showAgentEffortPicker() } } private func startNewAgentSessionFromShortcut() { @@ -284,4 +285,13 @@ final class GlobalKeyboardShortcutsCoordinator { userInfo: ["windowID": win.windowID] ) } + + private func showAgentEffortPicker() { + guard let win = guardedFocusedWindowState() else { return } + NotificationCenter.default.post( + name: .showAgentEffortPicker, + object: nil, + userInfo: ["windowID": win.windowID] + ) + } } diff --git a/Sources/RepoPrompt/App/Notifications/AppNotifications.swift b/Sources/RepoPrompt/App/Notifications/AppNotifications.swift index 326638ff0..bc4b08ee1 100644 --- a/Sources/RepoPrompt/App/Notifications/AppNotifications.swift +++ b/Sources/RepoPrompt/App/Notifications/AppNotifications.swift @@ -99,4 +99,8 @@ extension Notification.Name { /// Request that Agent Mode open the Agent/Model picker for the target window. /// userInfo: ["windowID": Int] static let showAgentModelPicker = Notification.Name("showAgentModelPicker") + + /// Request that Agent Mode open the active provider's effort picker for the target window. + /// userInfo: ["windowID": Int] + static let showAgentEffortPicker = Notification.Name("showAgentEffortPicker") } diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+ComposerUI.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+ComposerUI.swift index 0a95092a7..6c57459c2 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+ComposerUI.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+ComposerUI.swift @@ -44,6 +44,7 @@ extension AgentModeViewModel { selectedModelDisplayName: selectedModelDisplayName, selectedReasoningEffortRaw: selectedReasoningEffortRaw, selectedReasoningEffortDisplayName: selectedReasoningEffortDisplayName, + codexReasoningEffortOptions: reasoningEffortOptionsForCurrentSelection(), availableAgents: availableAgents, isProviderPickerLockedForCurrentTab: isProviderPickerLocked(tabID: tabID), lockedAgentSelectionMessage: lockedAgentSelectionMessage(tabID: tabID), diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/UI/AgentComposerUIModels.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/UI/AgentComposerUIModels.swift index 31cdaa370..73f518859 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/UI/AgentComposerUIModels.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/UI/AgentComposerUIModels.swift @@ -234,6 +234,7 @@ struct AgentComposerProps: Equatable { let selectedModelDisplayName: String let selectedReasoningEffortRaw: String? let selectedReasoningEffortDisplayName: String + let codexReasoningEffortOptions: [CodexReasoningEffort] let availableAgents: [AgentProviderKind] let isProviderPickerLockedForCurrentTab: Bool let lockedAgentSelectionMessage: String? @@ -266,6 +267,7 @@ struct AgentComposerProps: Equatable { selectedModelDisplayName: AgentModel.defaultModel.displayName, selectedReasoningEffortRaw: nil, selectedReasoningEffortDisplayName: "", + codexReasoningEffortOptions: [], availableAgents: [], isProviderPickerLockedForCurrentTab: false, lockedAgentSelectionMessage: nil, diff --git a/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift b/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift index d8813ed67..f8f946e77 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift @@ -25,7 +25,6 @@ struct AgentComposerActions { let modelOptions: (_ agent: AgentProviderKind, _ includeClaudeEffortVariants: Bool) -> [AgentModelOption] let canSelectAgentInCurrentChat: (_ agent: AgentProviderKind) -> Bool let selectAgentModel: (_ agent: AgentProviderKind, _ rawModel: String) -> Void - let reasoningEffortOptionsForCurrentSelection: () -> [CodexReasoningEffort] let selectReasoningEffort: (_ effort: CodexReasoningEffort?) -> Void let setAutoEditEnabled: (_ enabled: Bool) -> Void let setProviderPermissionLevel: (_ id: AgentProviderPermissionLevelID) -> Void @@ -136,7 +135,6 @@ struct AgentInputBar: View { agentModeVM.selectedAgent = agent agentModeVM.selectModel(rawModel: rawModel) }, - reasoningEffortOptionsForCurrentSelection: { agentModeVM.reasoningEffortOptionsForCurrentSelection() }, selectReasoningEffort: { effort in agentModeVM.selectReasoningEffort(effort) }, setAutoEditEnabled: { enabled in agentModeVM.setAutoEditEnabled(enabled) }, setProviderPermissionLevel: { id in agentModeVM.setProviderPermissionLevel(id) }, @@ -176,6 +174,29 @@ struct AgentInputBar: View { } } +enum AgentPickerOpenRequestWindowGuard { + static func shouldOpen( + requestWindowID: Int?, + composerWindowID: Int, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool, + modelControlsDisabled: Bool + ) -> Bool { + guard requestWindowID == composerWindowID else { return false } + guard let composerCurrentTabID, composerCurrentTabID == propsCurrentTabID else { return false } + guard hasAvailableAgentProviders else { return false } + guard !modelControlsDisabled else { return false } + // Locked provider pickers still open on click to show the lock message; + // shortcut requests intentionally match that behavior. + return true + } + + static func windowID(from userInfo: [AnyHashable: Any]?) -> Int? { + userInfo?["windowID"] as? Int + } +} + enum AgentModelPickerOpenRequestGuard { static func shouldOpen( notification: Notification, @@ -186,7 +207,7 @@ enum AgentModelPickerOpenRequestGuard { modelControlsDisabled: Bool ) -> Bool { shouldOpen( - requestWindowID: windowID(from: notification.userInfo), + requestWindowID: AgentPickerOpenRequestWindowGuard.windowID(from: notification.userInfo), composerWindowID: composerWindowID, composerCurrentTabID: composerCurrentTabID, propsCurrentTabID: propsCurrentTabID, @@ -203,17 +224,74 @@ enum AgentModelPickerOpenRequestGuard { hasAvailableAgentProviders: Bool, modelControlsDisabled: Bool ) -> Bool { - guard requestWindowID == composerWindowID else { return false } - guard let composerCurrentTabID, composerCurrentTabID == propsCurrentTabID else { return false } - guard hasAvailableAgentProviders else { return false } - guard !modelControlsDisabled else { return false } - // Locked provider pickers still open on click to show the lock message; - // shortcut requests intentionally match that behavior. - return true + AgentPickerOpenRequestWindowGuard.shouldOpen( + requestWindowID: requestWindowID, + composerWindowID: composerWindowID, + composerCurrentTabID: composerCurrentTabID, + propsCurrentTabID: propsCurrentTabID, + hasAvailableAgentProviders: hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled + ) } +} - private static func windowID(from userInfo: [AnyHashable: Any]?) -> Int? { - userInfo?["windowID"] as? Int +enum AgentEffortPickerOpenTarget: Equatable { + case codexReasoningEffort + case claudeEffortLevel +} + +enum AgentEffortPickerOpenRequestGuard { + static func target( + notification: Notification, + composerWindowID: Int, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool, + modelControlsDisabled: Bool, + selectedAgent: AgentProviderKind, + codexEffortsAvailable: Bool, + claudeEffortsAvailable: Bool + ) -> AgentEffortPickerOpenTarget? { + target( + requestWindowID: AgentPickerOpenRequestWindowGuard.windowID(from: notification.userInfo), + composerWindowID: composerWindowID, + composerCurrentTabID: composerCurrentTabID, + propsCurrentTabID: propsCurrentTabID, + hasAvailableAgentProviders: hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled, + selectedAgent: selectedAgent, + codexEffortsAvailable: codexEffortsAvailable, + claudeEffortsAvailable: claudeEffortsAvailable + ) + } + + static func target( + requestWindowID: Int?, + composerWindowID: Int, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool, + modelControlsDisabled: Bool, + selectedAgent: AgentProviderKind, + codexEffortsAvailable: Bool, + claudeEffortsAvailable: Bool + ) -> AgentEffortPickerOpenTarget? { + guard AgentPickerOpenRequestWindowGuard.shouldOpen( + requestWindowID: requestWindowID, + composerWindowID: composerWindowID, + composerCurrentTabID: composerCurrentTabID, + propsCurrentTabID: propsCurrentTabID, + hasAvailableAgentProviders: hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled + ) else { return nil } + + if selectedAgent == .codexExec, codexEffortsAvailable { + return .codexReasoningEffort + } + if selectedAgent.usesClaudeTooling, claudeEffortsAvailable { + return .claudeEffortLevel + } + return nil } } @@ -308,6 +386,8 @@ struct AgentComposerView: View, Equatable { @State private var steeringUnsupportedMessage: String? = nil @State private var steeringUnsupportedDismissTask: Task? @State private var modelPickerOpenRequestCount: Int = 0 + @State private var codexEffortPickerOpenRequestCount: Int = 0 + @State private var claudeEffortPickerOpenRequestCount: Int = 0 @State private var modelMenuSnapshotByAgent: [AgentProviderKind: [AgentModelOption]]? = nil @State private var modelMenuSnapshotReleaseTask: Task? = nil @@ -397,6 +477,16 @@ struct AgentComposerView: View, Equatable { props.areModelControlsDisabled } + private var codexEffortsAreAvailable: Bool { + props.selectedAgent == .codexExec && !props.codexReasoningEffortOptions.isEmpty + } + + private var claudeEffortsAreAvailable: Bool { + props.selectedAgent.usesClaudeTooling + && props.providerControls?.claudeTools != nil + && !supportedClaudeEffortsForCurrentSelection().isEmpty + } + private var modelControlsDisabledTooltip: String { "Model and effort controls are locked while this session is controlled by an MCP agent." } @@ -591,6 +681,25 @@ struct AgentComposerView: View, Equatable { ) else { return } modelPickerOpenRequestCount += 1 } + .onReceive(NotificationCenter.default.publisher(for: .showAgentEffortPicker)) { notification in + guard let target = AgentEffortPickerOpenRequestGuard.target( + notification: notification, + composerWindowID: windowID, + composerCurrentTabID: currentTabID, + propsCurrentTabID: props.currentTabID, + hasAvailableAgentProviders: props.hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled, + selectedAgent: props.selectedAgent, + codexEffortsAvailable: codexEffortsAreAvailable, + claudeEffortsAvailable: claudeEffortsAreAvailable + ) else { return } + switch target { + case .codexReasoningEffort: + codexEffortPickerOpenRequestCount += 1 + case .claudeEffortLevel: + claudeEffortPickerOpenRequestCount += 1 + } + } .onDrop(of: [UTType.fileURL, UTType.image], isTargeted: $isImageDropTargeted, perform: handleImageDrop(providers:)) .overlay(imageDropOutline) } @@ -999,22 +1108,12 @@ struct AgentComposerView: View, Equatable { @ViewBuilder private var reasoningEffortPicker: some View { if props.selectedAgent == .codexExec { - let efforts = actions.reasoningEffortOptionsForCurrentSelection() - Menu { - ForEach(efforts, id: \.rawValue) { effort in - Button { - actions.selectReasoningEffort(effort) - } label: { - HStack { - Text(effort.displayName) - if props.selectedReasoningEffortRaw == effort.rawValue { - Spacer() - Image(systemName: "checkmark") - } - } - } - } - } label: { + let efforts = props.codexReasoningEffortOptions + StableMenuButton( + items: { reasoningEffortMenuItems(efforts: efforts) }, + triggerStyle: .plain, + openRequestCount: codexEffortPickerOpenRequestCount + ) { HStack(spacing: 4) { Text(props.selectedReasoningEffortDisplayName) .font(fontPreset.swiftUIFont(sizeAtNormal: 11)) @@ -1025,7 +1124,6 @@ struct AgentComposerView: View, Equatable { .background(pickerChipColor) .cornerRadius(4) } - .menuStyle(.borderlessButton) .disabled(efforts.isEmpty || modelControlsDisabled) .opacity(modelControlsDisabled ? 0.55 : 1.0) .hoverTooltip(modelControlsDisabled ? modelControlsDisabledTooltip : "Codex reasoning effort") @@ -1033,30 +1131,28 @@ struct AgentComposerView: View, Equatable { } } + private func reasoningEffortMenuItems(efforts: [CodexReasoningEffort]) -> [StableMenuItem] { + efforts.map { effort in + StableMenuItem.action( + effort.displayName, + isSelected: props.selectedReasoningEffortRaw == effort.rawValue + ) { + actions.selectReasoningEffort(effort) + } + } + } + @ViewBuilder private var claudeEffortPicker: some View { if props.selectedAgent.usesClaudeTooling, let claudeTools = props.providerControls?.claudeTools { - let efforts = AgentModelCatalog.supportedClaudeEfforts( - forSelectedModelRaw: props.selectedModelRaw, - agentKind: props.selectedAgent - ) - Menu { - ForEach(efforts, id: \.rawValue) { level in - Button { - actions.setClaudeEffortLevel(level) - } label: { - HStack { - Text(level.displayName) - if claudeTools.effortLevel == level { - Spacer() - Image(systemName: "checkmark") - } - } - } - } - } label: { + let efforts = supportedClaudeEffortsForCurrentSelection() + StableMenuButton( + items: { claudeEffortMenuItems(efforts: efforts, selectedLevel: claudeTools.effortLevel) }, + triggerStyle: .plain, + openRequestCount: claudeEffortPickerOpenRequestCount + ) { HStack(spacing: 4) { Text(claudeTools.effortLevel.displayName) .font(fontPreset.swiftUIFont(sizeAtNormal: 11)) @@ -1067,7 +1163,6 @@ struct AgentComposerView: View, Equatable { .background(pickerChipColor) .cornerRadius(4) } - .menuStyle(.borderlessButton) .disabled(modelControlsDisabled || efforts.isEmpty) .opacity(modelControlsDisabled ? 0.55 : 1.0) .fixedSize() @@ -1075,6 +1170,27 @@ struct AgentComposerView: View, Equatable { } } + private func claudeEffortMenuItems( + efforts: [ClaudeCodeEffortLevel], + selectedLevel: ClaudeCodeEffortLevel + ) -> [StableMenuItem] { + efforts.map { level in + StableMenuItem.action( + level.displayName, + isSelected: selectedLevel == level + ) { + actions.setClaudeEffortLevel(level) + } + } + } + + private func supportedClaudeEffortsForCurrentSelection() -> [ClaudeCodeEffortLevel] { + AgentModelCatalog.supportedClaudeEfforts( + forSelectedModelRaw: props.selectedModelRaw, + agentKind: props.selectedAgent + ) + } + @ViewBuilder private var codexToolsButton: some View { if props.selectedAgent == .codexExec { diff --git a/Sources/RepoPrompt/Features/Settings/Views/KeyboardShortcutsSettingsView.swift b/Sources/RepoPrompt/Features/Settings/Views/KeyboardShortcutsSettingsView.swift index d504ff1cc..6c3a54ee8 100644 --- a/Sources/RepoPrompt/Features/Settings/Views/KeyboardShortcutsSettingsView.swift +++ b/Sources/RepoPrompt/Features/Settings/Views/KeyboardShortcutsSettingsView.swift @@ -27,7 +27,8 @@ enum KeyboardShortcutCatalog { ), .init(id: "agent-nav-current", title: "Show Agent Session Switcher", detail: "Jump between Agent sessions in the focused window.", name: .showCurrentWindowAgentNavigationHUD), .init(id: "agent-nav-all", title: "Search all Agent sessions", detail: "Jump to active or recent Agent sessions across windows.", name: .showAllAgentsNavigationHUD), - .init(id: "agent-model-picker", title: "Open Agent/Model picker", detail: "Open the model picker for the focused Agent session.", name: .showAgentModelPicker) + .init(id: "agent-model-picker", title: "Open Agent/Model picker", detail: "Open the model picker for the focused Agent session.", name: .showAgentModelPicker), + .init(id: "agent-effort-picker", title: "Open Agent effort picker", detail: "Open the effort picker exposed by the focused Agent session's selected provider.", name: .showAgentEffortPicker) ] ), KeyboardShortcutCatalogSection( diff --git a/Sources/RepoPrompt/Infrastructure/Utilities/Shortcuts.swift b/Sources/RepoPrompt/Infrastructure/Utilities/Shortcuts.swift index cf8999a39..8fd13fb2f 100644 --- a/Sources/RepoPrompt/Infrastructure/Utilities/Shortcuts.swift +++ b/Sources/RepoPrompt/Infrastructure/Utilities/Shortcuts.swift @@ -48,6 +48,8 @@ extension KeyboardShortcuts.Name { static let showAllAgentsNavigationHUD = Self("showAllAgentsNavigationHUD", default: .init(.k, modifiers: [.command, .shift])) /// Open the focused Agent session's Agent/Model picker. static let showAgentModelPicker = Self("showAgentModelPicker", default: .init(.m, modifiers: [.command, .shift])) + /// Open the focused Agent session's active effort picker. Unbound by default. + static let showAgentEffortPicker = Self("showAgentEffortPicker") /// Cycle to the previous root Agent session row. static let previousParentAgentSession = Self("previousParentAgentSession", default: .init(.leftBracket, modifiers: [.command, .option])) /// Cycle to the next root Agent session row. diff --git a/Tests/RepoPromptTests/AgentMode/AgentModelPickerOpenRequestGuardTests.swift b/Tests/RepoPromptTests/AgentMode/AgentModelPickerOpenRequestGuardTests.swift index 11c7b22b6..e4c580b46 100644 --- a/Tests/RepoPromptTests/AgentMode/AgentModelPickerOpenRequestGuardTests.swift +++ b/Tests/RepoPromptTests/AgentMode/AgentModelPickerOpenRequestGuardTests.swift @@ -105,3 +105,206 @@ final class AgentModelPickerOpenRequestGuardTests: XCTestCase { ) } } + +final class AgentEffortPickerOpenRequestGuardTests: XCTestCase { + func testRoutesCodexEffortPickerWhenCodexEffortsAreAvailable() { + let tabID = UUID() + XCTAssertEqual( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .codexExec, + codexEffortsAvailable: true + ), + .codexReasoningEffort + ) + } + + func testNotificationOverloadRoutesByWindowUserInfo() { + let tabID = UUID() + let notification = Notification( + name: .showAgentEffortPicker, + object: nil, + userInfo: ["windowID": 42] + ) + + XCTAssertEqual( + AgentEffortPickerOpenRequestGuard.target( + notification: notification, + composerWindowID: 42, + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + hasAvailableAgentProviders: true, + modelControlsDisabled: false, + selectedAgent: .codexExec, + codexEffortsAvailable: true, + claudeEffortsAvailable: false + ), + .codexReasoningEffort + ) + } + + func testRoutesClaudeEffortPickerWhenClaudeEffortsAreAvailable() { + let tabID = UUID() + XCTAssertEqual( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .claudeCode, + claudeEffortsAvailable: true + ), + .claudeEffortLevel + ) + } + + func testRejectsAgentsWithoutEffortPicker() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .openCode, + codexEffortsAvailable: false, + claudeEffortsAvailable: false + ) + ) + } + + func testRejectsClaudeWhenOnlyCodexEffortsAreAvailable() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .claudeCode, + codexEffortsAvailable: true, + claudeEffortsAvailable: false + ) + ) + } + + func testRejectsCodexWhenOnlyClaudeEffortsAreAvailable() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .codexExec, + codexEffortsAvailable: false, + claudeEffortsAvailable: true + ) + ) + } + + func testRejectsCodexWhenNoEffortsAreAvailable() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .codexExec, + codexEffortsAvailable: false + ) + ) + } + + func testRejectsClaudeWhenNoEffortsAreAvailable() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .claudeCode, + claudeEffortsAvailable: false + ) + ) + } + + func testRejectsMismatchedWindow() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + requestWindowID: 41, + composerWindowID: 42, + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .codexExec, + codexEffortsAvailable: true + ) + ) + } + + func testRejectsMissingWindow() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + requestWindowID: nil, + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + selectedAgent: .codexExec, + codexEffortsAvailable: true + ) + ) + } + + func testRejectsMismatchedTab() { + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: UUID(), + propsCurrentTabID: UUID(), + selectedAgent: .codexExec, + codexEffortsAvailable: true + ) + ) + } + + func testRejectsMissingProviders() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + hasAvailableAgentProviders: false, + selectedAgent: .codexExec, + codexEffortsAvailable: true + ) + ) + } + + func testRejectsDisabledModelControls() { + let tabID = UUID() + XCTAssertNil( + effortPickerShortcutTarget( + composerCurrentTabID: tabID, + propsCurrentTabID: tabID, + modelControlsDisabled: true, + selectedAgent: .codexExec, + codexEffortsAvailable: true + ) + ) + } + + private func effortPickerShortcutTarget( + requestWindowID: Int? = 42, + composerWindowID: Int = 42, + composerCurrentTabID: UUID?, + propsCurrentTabID: UUID?, + hasAvailableAgentProviders: Bool = true, + modelControlsDisabled: Bool = false, + selectedAgent: AgentProviderKind, + codexEffortsAvailable: Bool = false, + claudeEffortsAvailable: Bool = false + ) -> AgentEffortPickerOpenTarget? { + AgentEffortPickerOpenRequestGuard.target( + requestWindowID: requestWindowID, + composerWindowID: composerWindowID, + composerCurrentTabID: composerCurrentTabID, + propsCurrentTabID: propsCurrentTabID, + hasAvailableAgentProviders: hasAvailableAgentProviders, + modelControlsDisabled: modelControlsDisabled, + selectedAgent: selectedAgent, + codexEffortsAvailable: codexEffortsAvailable, + claudeEffortsAvailable: claudeEffortsAvailable + ) + } +} diff --git a/Tests/RepoPromptTests/App/KeyboardShortcutCatalogTests.swift b/Tests/RepoPromptTests/App/KeyboardShortcutCatalogTests.swift index c21b90d6f..e4be5ed4e 100644 --- a/Tests/RepoPromptTests/App/KeyboardShortcutCatalogTests.swift +++ b/Tests/RepoPromptTests/App/KeyboardShortcutCatalogTests.swift @@ -4,9 +4,7 @@ import XCTest final class KeyboardShortcutCatalogTests: XCTestCase { func testAgentLayoutCatalogContainsAgentModelPickerShortcut() throws { - let section = try XCTUnwrap( - KeyboardShortcutCatalog.sections.first(where: { $0.id == "agent-layout" }) - ) + let section = try agentLayoutSection() let binding = try XCTUnwrap( section.bindings.first(where: { $0.id == "agent-model-picker" }) @@ -15,4 +13,22 @@ final class KeyboardShortcutCatalogTests: XCTestCase { XCTAssertEqual(binding.title, "Open Agent/Model picker") XCTAssertEqual(binding.name, KeyboardShortcuts.Name.showAgentModelPicker) } + + func testAgentLayoutCatalogContainsUnboundAgentEffortPickerShortcut() throws { + let section = try agentLayoutSection() + + let binding = try XCTUnwrap( + section.bindings.first(where: { $0.id == "agent-effort-picker" }) + ) + + XCTAssertEqual(binding.title, "Open Agent effort picker") + XCTAssertEqual(binding.name, KeyboardShortcuts.Name.showAgentEffortPicker) + XCTAssertNil(binding.name.defaultShortcut) + } + + private func agentLayoutSection() throws -> KeyboardShortcutCatalogSection { + try XCTUnwrap( + KeyboardShortcutCatalog.sections.first(where: { $0.id == "agent-layout" }) + ) + } } From 1eb6b50a24d7a1fa4b2221e6e54b182aa519bf3f Mon Sep 17 00:00:00 2001 From: Atila Alacan Date: Sun, 5 Jul 2026 08:34:39 +0200 Subject: [PATCH 3/5] fix(agent): align effort picker trigger styling --- .../Features/AgentMode/Views/AgentInputBar.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift b/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift index f8f946e77..4e502c9be 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift @@ -1117,12 +1117,13 @@ struct AgentComposerView: View, Equatable { HStack(spacing: 4) { Text(props.selectedReasoningEffortDisplayName) .font(fontPreset.swiftUIFont(sizeAtNormal: 11)) + Image(systemName: "chevron.down") + .font(fontPreset.swiftUIFont(sizeAtNormal: 8, weight: .semibold)) + .foregroundColor(.secondary) } - .foregroundColor(.secondary) + .foregroundColor(.primary) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(pickerChipColor) - .cornerRadius(4) } .disabled(efforts.isEmpty || modelControlsDisabled) .opacity(modelControlsDisabled ? 0.55 : 1.0) @@ -1156,12 +1157,13 @@ struct AgentComposerView: View, Equatable { HStack(spacing: 4) { Text(claudeTools.effortLevel.displayName) .font(fontPreset.swiftUIFont(sizeAtNormal: 11)) + Image(systemName: "chevron.down") + .font(fontPreset.swiftUIFont(sizeAtNormal: 8, weight: .semibold)) + .foregroundColor(.secondary) } - .foregroundColor(.secondary) + .foregroundColor(.primary) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(pickerChipColor) - .cornerRadius(4) } .disabled(modelControlsDisabled || efforts.isEmpty) .opacity(modelControlsDisabled ? 0.55 : 1.0) From e465154ac3b59d9dc84c85173afd848991eca1fa Mon Sep 17 00:00:00 2001 From: Atila Alacan Date: Wed, 12 Aug 2026 23:33:36 +0200 Subject: [PATCH 4/5] fix(agent-mode): guard deferred picker presentation --- .../AgentMode/Views/AgentInputBar.swift | 61 ++++++- .../UI/Components/StableMenuButton.swift | 117 +++++++++++-- .../UI/StableMenuPresenterTests.swift | 155 ++++++++++++++++++ 3 files changed, 315 insertions(+), 18 deletions(-) create mode 100644 Tests/RepoPromptTests/UI/StableMenuPresenterTests.swift diff --git a/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift b/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift index 4e502c9be..71b4fb976 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Views/AgentInputBar.swift @@ -240,6 +240,20 @@ enum AgentEffortPickerOpenTarget: Equatable { case claudeEffortLevel } +private struct AgentPickerPresentationID: Hashable { + enum Kind: Hashable { + case model + case codexEffort + case claudeEffort + } + + let kind: Kind + let tabID: UUID? + let selectedAgent: AgentProviderKind + let selectedModelRaw: String + let optionRawValues: [String] +} + enum AgentEffortPickerOpenRequestGuard { static func target( notification: Notification, @@ -487,6 +501,43 @@ struct AgentComposerView: View, Equatable { && !supportedClaudeEffortsForCurrentSelection().isEmpty } + private var modelPickerProgrammaticPresentationAllowed: Bool { + currentTabID != nil + && currentTabID == props.currentTabID + && props.hasAvailableAgentProviders + && !modelControlsDisabled + } + + private var modelPickerPresentationID: AgentPickerPresentationID { + AgentPickerPresentationID( + kind: .model, + tabID: props.currentTabID, + selectedAgent: props.selectedAgent, + selectedModelRaw: props.selectedModelRaw, + optionRawValues: props.availableAgents.map(\.rawValue) + ) + } + + private var codexEffortPickerPresentationID: AgentPickerPresentationID { + AgentPickerPresentationID( + kind: .codexEffort, + tabID: props.currentTabID, + selectedAgent: props.selectedAgent, + selectedModelRaw: props.selectedModelRaw, + optionRawValues: props.codexReasoningEffortOptions.map(\.rawValue) + ) + } + + private var claudeEffortPickerPresentationID: AgentPickerPresentationID { + AgentPickerPresentationID( + kind: .claudeEffort, + tabID: props.currentTabID, + selectedAgent: props.selectedAgent, + selectedModelRaw: props.selectedModelRaw, + optionRawValues: supportedClaudeEffortsForCurrentSelection().map(\.rawValue) + ) + } + private var modelControlsDisabledTooltip: String { "Model and effort controls are locked while this session is controlled by an MCP agent." } @@ -967,6 +1018,8 @@ struct AgentComposerView: View, Equatable { items: agentProviderModelMenuItems, triggerStyle: .plain, openRequestCount: modelPickerOpenRequestCount, + programmaticPresentationID: modelPickerPresentationID, + isProgrammaticPresentationAllowed: modelPickerProgrammaticPresentationAllowed, onOpen: captureModelMenuSnapshot ) { HStack(spacing: 4) { @@ -1112,7 +1165,9 @@ struct AgentComposerView: View, Equatable { StableMenuButton( items: { reasoningEffortMenuItems(efforts: efforts) }, triggerStyle: .plain, - openRequestCount: codexEffortPickerOpenRequestCount + openRequestCount: codexEffortPickerOpenRequestCount, + programmaticPresentationID: codexEffortPickerPresentationID, + isProgrammaticPresentationAllowed: modelPickerProgrammaticPresentationAllowed && codexEffortsAreAvailable ) { HStack(spacing: 4) { Text(props.selectedReasoningEffortDisplayName) @@ -1152,7 +1207,9 @@ struct AgentComposerView: View, Equatable { StableMenuButton( items: { claudeEffortMenuItems(efforts: efforts, selectedLevel: claudeTools.effortLevel) }, triggerStyle: .plain, - openRequestCount: claudeEffortPickerOpenRequestCount + openRequestCount: claudeEffortPickerOpenRequestCount, + programmaticPresentationID: claudeEffortPickerPresentationID, + isProgrammaticPresentationAllowed: modelPickerProgrammaticPresentationAllowed && claudeEffortsAreAvailable ) { HStack(spacing: 4) { Text(claudeTools.effortLevel.displayName) diff --git a/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift b/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift index d9f5ff6e7..1219eb367 100644 --- a/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift +++ b/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift @@ -16,21 +16,28 @@ struct StableMenuButton: View { let items: () -> [StableMenuItem] let triggerStyle: TriggerStyle let openRequestCount: Int + let programmaticPresentationID: AnyHashable? + let isProgrammaticPresentationAllowed: Bool let onOpen: @MainActor () -> Void @ViewBuilder let label: () -> Label + @Environment(\.isEnabled) private var isEnabled @StateObject private var presenter = StableMenuPresenter() init( items: @escaping () -> [StableMenuItem], triggerStyle: TriggerStyle = .automatic, openRequestCount: Int = 0, + programmaticPresentationID: AnyHashable? = nil, + isProgrammaticPresentationAllowed: Bool = true, onOpen: @escaping @MainActor () -> Void = {}, @ViewBuilder label: @escaping () -> Label ) { self.items = items self.triggerStyle = triggerStyle self.openRequestCount = openRequestCount + self.programmaticPresentationID = programmaticPresentationID + self.isProgrammaticPresentationAllowed = isProgrammaticPresentationAllowed self.onOpen = onOpen self.label = label } @@ -48,24 +55,38 @@ struct StableMenuButton: View { private var button: some View { Button { - presentMenu() + presentMenu(expectedProgrammaticPresentationID: nil, requiresProgrammaticPermission: false) } label: { label() } .background( - StableMenuAnchorView(presenter: presenter) - .allowsHitTesting(false) + StableMenuAnchorView( + presenter: presenter, + isEnabled: isEnabled, + programmaticPresentationID: programmaticPresentationID, + isProgrammaticPresentationAllowed: isProgrammaticPresentationAllowed + ) + .allowsHitTesting(false) ) .onChange(of: openRequestCount) { - DispatchQueue.main.async { - presentMenu() - } + presenter.enqueueProgrammaticPresentation( + expectedPresentationID: programmaticPresentationID, + onOpen: onOpen, + items: items + ) } } - private func presentMenu() { - onOpen() - presenter.present(items()) + private func presentMenu( + expectedProgrammaticPresentationID: AnyHashable?, + requiresProgrammaticPermission: Bool + ) { + presenter.present( + expectedProgrammaticPresentationID: expectedProgrammaticPresentationID, + requiresProgrammaticPermission: requiresProgrammaticPermission, + onOpen: onOpen, + items: items + ) } } @@ -215,21 +236,73 @@ private extension NSMenu { } @MainActor -private final class StableMenuPresenter: NSObject, ObservableObject, NSMenuDelegate { +final class StableMenuPresenter: NSObject, ObservableObject, NSMenuDelegate { + typealias PopUpMenu = (NSMenu, NSPoint, NSView) -> Void + weak var anchorView: NSView? + private var isEnabled = true + private var programmaticPresentationID: AnyHashable? + private var isProgrammaticPresentationAllowed = true private var retainedMenu: NSMenu? + private let popUpMenu: PopUpMenu + + init(popUpMenu: @escaping PopUpMenu = { menu, point, anchorView in + menu.popUp(positioning: nil, at: point, in: anchorView) + }) { + self.popUpMenu = popUpMenu + } - func present(_ items: [StableMenuItem]) { - guard !items.isEmpty else { return } + func update( + anchorView: NSView, + isEnabled: Bool, + programmaticPresentationID: AnyHashable?, + isProgrammaticPresentationAllowed: Bool + ) { + self.anchorView = anchorView + self.isEnabled = isEnabled + self.programmaticPresentationID = programmaticPresentationID + self.isProgrammaticPresentationAllowed = isProgrammaticPresentationAllowed + } + + func enqueueProgrammaticPresentation( + expectedPresentationID: AnyHashable?, + onOpen: @escaping () -> Void, + items: @escaping () -> [StableMenuItem] + ) { + DispatchQueue.main.async { [weak self] in + self?.present( + expectedProgrammaticPresentationID: expectedPresentationID, + requiresProgrammaticPermission: true, + onOpen: onOpen, + items: items + ) + } + } + + func present( + expectedProgrammaticPresentationID: AnyHashable?, + requiresProgrammaticPermission: Bool, + onOpen: () -> Void, + items: () -> [StableMenuItem] + ) { + guard isEnabled else { return } guard let anchorView, anchorView.window != nil else { return } + if requiresProgrammaticPermission { + guard isProgrammaticPresentationAllowed else { return } + guard expectedProgrammaticPresentationID == programmaticPresentationID else { return } + } + + onOpen() + let menuItems = items() + guard !menuItems.isEmpty else { return } retainedMenu?.cancelTracking() - let menu = NSMenu.stableMenu(from: items, fontPreset: FontScalePreset.current) + let menu = NSMenu.stableMenu(from: menuItems, fontPreset: FontScalePreset.current) menu.delegate = self retainedMenu = menu let popupPoint = NSPoint(x: 0, y: anchorView.bounds.height + 2) - menu.popUp(positioning: nil, at: popupPoint, in: anchorView) + popUpMenu(menu, popupPoint, anchorView) } func menuDidClose(_ menu: NSMenu) { @@ -253,19 +326,31 @@ private final class StableMenuActionBox: NSObject { @MainActor private struct StableMenuAnchorView: NSViewRepresentable { @ObservedObject var presenter: StableMenuPresenter + let isEnabled: Bool + let programmaticPresentationID: AnyHashable? + let isProgrammaticPresentationAllowed: Bool func makeNSView(context: Context) -> NSView { let view = NSView(frame: .zero) - presenter.anchorView = view + updatePresenter(anchorView: view) return view } func updateNSView(_ nsView: NSView, context: Context) { - presenter.anchorView = nsView + updatePresenter(anchorView: nsView) } static func dismantleNSView(_ nsView: NSView, coordinator: ()) { // Do not cancel tracking here. SwiftUI may rebuild the trigger while the AppKit // menu is open; retaining the menu through `StableMenuPresenter` is the point. } + + private func updatePresenter(anchorView: NSView) { + presenter.update( + anchorView: anchorView, + isEnabled: isEnabled, + programmaticPresentationID: programmaticPresentationID, + isProgrammaticPresentationAllowed: isProgrammaticPresentationAllowed + ) + } } diff --git a/Tests/RepoPromptTests/UI/StableMenuPresenterTests.swift b/Tests/RepoPromptTests/UI/StableMenuPresenterTests.swift new file mode 100644 index 000000000..e2fc80ed1 --- /dev/null +++ b/Tests/RepoPromptTests/UI/StableMenuPresenterTests.swift @@ -0,0 +1,155 @@ +import AppKit +@testable import RepoPromptApp +import XCTest + +@MainActor +final class StableMenuPresenterTests: XCTestCase { + func testRepeatedProgrammaticRequestsPresentTwice() async { + var openCount = 0 + var presentationCount = 0 + let presenter = StableMenuPresenter { _, _, _ in + presentationCount += 1 + } + let fixture = makeAttachedAnchor() + presenter.update( + anchorView: fixture.anchor, + isEnabled: true, + programmaticPresentationID: "current", + isProgrammaticPresentationAllowed: true + ) + + enqueueProgrammaticPresentation(presenter, expectedID: "current") { openCount += 1 } + enqueueProgrammaticPresentation(presenter, expectedID: "current") { openCount += 1 } + await drainMainQueue() + + XCTAssertEqual(openCount, 2) + XCTAssertEqual(presentationCount, 2) + } + + func testDeferredProgrammaticRequestDoesNotOpenAfterControlBecomesDisabled() async { + var didOpen = false + let presenter = StableMenuPresenter { _, _, _ in + XCTFail("Disabled control must not present a menu") + } + let fixture = makeAttachedAnchor() + presenter.update( + anchorView: fixture.anchor, + isEnabled: true, + programmaticPresentationID: "current", + isProgrammaticPresentationAllowed: true + ) + + enqueueProgrammaticPresentation(presenter, expectedID: "current") { didOpen = true } + presenter.update( + anchorView: fixture.anchor, + isEnabled: false, + programmaticPresentationID: "current", + isProgrammaticPresentationAllowed: false + ) + await drainMainQueue() + + XCTAssertFalse(didOpen) + } + + func testDeferredProgrammaticRequestDoesNotOpenAfterAnchorDetaches() async { + var didOpen = false + let presenter = StableMenuPresenter { _, _, _ in + XCTFail("Detached control must not present a menu") + } + let fixture = makeAttachedAnchor() + presenter.update( + anchorView: fixture.anchor, + isEnabled: true, + programmaticPresentationID: "current", + isProgrammaticPresentationAllowed: true + ) + + enqueueProgrammaticPresentation(presenter, expectedID: "current") { didOpen = true } + fixture.anchor.removeFromSuperview() + await drainMainQueue() + + XCTAssertFalse(didOpen) + } + + func testDeferredProgrammaticRequestDoesNotOpenForStalePresentationIdentity() async { + var didOpen = false + let presenter = StableMenuPresenter { _, _, _ in + XCTFail("Stale request must not present a menu") + } + let fixture = makeAttachedAnchor() + presenter.update( + anchorView: fixture.anchor, + isEnabled: true, + programmaticPresentationID: "old-tab-or-provider", + isProgrammaticPresentationAllowed: true + ) + + enqueueProgrammaticPresentation(presenter, expectedID: "old-tab-or-provider") { didOpen = true } + presenter.update( + anchorView: fixture.anchor, + isEnabled: true, + programmaticPresentationID: "new-tab-or-provider", + isProgrammaticPresentationAllowed: true + ) + await drainMainQueue() + + XCTAssertFalse(didOpen) + } + + func testOrdinaryClickPresentationDoesNotRequireProgrammaticPermission() { + var didOpen = false + var didPresent = false + let presenter = StableMenuPresenter { _, _, _ in + didPresent = true + } + let fixture = makeAttachedAnchor() + presenter.update( + anchorView: fixture.anchor, + isEnabled: true, + programmaticPresentationID: "current", + isProgrammaticPresentationAllowed: false + ) + + presenter.present( + expectedProgrammaticPresentationID: nil, + requiresProgrammaticPermission: false, + onOpen: { didOpen = true }, + items: { [.action("Item") {}] } + ) + + XCTAssertTrue(didOpen) + XCTAssertTrue(didPresent) + } + + private func enqueueProgrammaticPresentation( + _ presenter: StableMenuPresenter, + expectedID: AnyHashable, + onOpen: @escaping () -> Void + ) { + presenter.enqueueProgrammaticPresentation( + expectedPresentationID: expectedID, + onOpen: onOpen, + items: { [.action("Item") {}] } + ) + } + + private func drainMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + } + + private func makeAttachedAnchor() -> (window: NSWindow, anchor: NSView) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 80, height: 24)) + window.contentView?.addSubview(anchor) + return (window, anchor) + } +} From 2f7965672176c29dca490708759509e496de53a6 Mon Sep 17 00:00:00 2001 From: Atila Alacan Date: Thu, 13 Aug 2026 07:02:01 +0200 Subject: [PATCH 5/5] fix(agent-mode): use fresh picker menu state --- .../UI/Components/StableMenuButton.swift | 37 +-- .../UI/StableMenuPresenterTests.swift | 211 +++++++++++++----- 2 files changed, 174 insertions(+), 74 deletions(-) diff --git a/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift b/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift index 1219eb367..c7320b6b3 100644 --- a/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift +++ b/Sources/RepoPrompt/Infrastructure/UI/Components/StableMenuButton.swift @@ -64,16 +64,14 @@ struct StableMenuButton: View { presenter: presenter, isEnabled: isEnabled, programmaticPresentationID: programmaticPresentationID, - isProgrammaticPresentationAllowed: isProgrammaticPresentationAllowed + isProgrammaticPresentationAllowed: isProgrammaticPresentationAllowed, + onOpen: onOpen, + items: items ) .allowsHitTesting(false) ) .onChange(of: openRequestCount) { - presenter.enqueueProgrammaticPresentation( - expectedPresentationID: programmaticPresentationID, - onOpen: onOpen, - items: items - ) + presenter.enqueueProgrammaticPresentation(expectedPresentationID: programmaticPresentationID) } } @@ -243,6 +241,8 @@ final class StableMenuPresenter: NSObject, ObservableObject, NSMenuDelegate { private var isEnabled = true private var programmaticPresentationID: AnyHashable? private var isProgrammaticPresentationAllowed = true + private var currentOnOpen: (() -> Void)? + private var currentItems: (() -> [StableMenuItem])? private var retainedMenu: NSMenu? private let popUpMenu: PopUpMenu @@ -256,25 +256,26 @@ final class StableMenuPresenter: NSObject, ObservableObject, NSMenuDelegate { anchorView: NSView, isEnabled: Bool, programmaticPresentationID: AnyHashable?, - isProgrammaticPresentationAllowed: Bool + isProgrammaticPresentationAllowed: Bool, + onOpen: @escaping () -> Void, + items: @escaping () -> [StableMenuItem] ) { self.anchorView = anchorView self.isEnabled = isEnabled self.programmaticPresentationID = programmaticPresentationID self.isProgrammaticPresentationAllowed = isProgrammaticPresentationAllowed + currentOnOpen = onOpen + currentItems = items } - func enqueueProgrammaticPresentation( - expectedPresentationID: AnyHashable?, - onOpen: @escaping () -> Void, - items: @escaping () -> [StableMenuItem] - ) { + func enqueueProgrammaticPresentation(expectedPresentationID: AnyHashable?) { DispatchQueue.main.async { [weak self] in - self?.present( + guard let self, let currentOnOpen, let currentItems else { return } + present( expectedProgrammaticPresentationID: expectedPresentationID, requiresProgrammaticPermission: true, - onOpen: onOpen, - items: items + onOpen: currentOnOpen, + items: currentItems ) } } @@ -329,6 +330,8 @@ private struct StableMenuAnchorView: NSViewRepresentable { let isEnabled: Bool let programmaticPresentationID: AnyHashable? let isProgrammaticPresentationAllowed: Bool + let onOpen: () -> Void + let items: () -> [StableMenuItem] func makeNSView(context: Context) -> NSView { let view = NSView(frame: .zero) @@ -350,7 +353,9 @@ private struct StableMenuAnchorView: NSViewRepresentable { anchorView: anchorView, isEnabled: isEnabled, programmaticPresentationID: programmaticPresentationID, - isProgrammaticPresentationAllowed: isProgrammaticPresentationAllowed + isProgrammaticPresentationAllowed: isProgrammaticPresentationAllowed, + onOpen: onOpen, + items: items ) } } diff --git a/Tests/RepoPromptTests/UI/StableMenuPresenterTests.swift b/Tests/RepoPromptTests/UI/StableMenuPresenterTests.swift index e2fc80ed1..f0aa1b19b 100644 --- a/Tests/RepoPromptTests/UI/StableMenuPresenterTests.swift +++ b/Tests/RepoPromptTests/UI/StableMenuPresenterTests.swift @@ -6,94 +6,142 @@ import XCTest final class StableMenuPresenterTests: XCTestCase { func testRepeatedProgrammaticRequestsPresentTwice() async { var openCount = 0 + var itemCount = 0 var presentationCount = 0 let presenter = StableMenuPresenter { _, _, _ in presentationCount += 1 } let fixture = makeAttachedAnchor() - presenter.update( - anchorView: fixture.anchor, - isEnabled: true, - programmaticPresentationID: "current", - isProgrammaticPresentationAllowed: true + update( + presenter, + anchor: fixture.anchor, + onOpen: { openCount += 1 }, + items: { + itemCount += 1 + return [.action("Item") {}] + } ) - enqueueProgrammaticPresentation(presenter, expectedID: "current") { openCount += 1 } - enqueueProgrammaticPresentation(presenter, expectedID: "current") { openCount += 1 } + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "current") + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "current") await drainMainQueue() XCTAssertEqual(openCount, 2) + XCTAssertEqual(itemCount, 2) XCTAssertEqual(presentationCount, 2) } - func testDeferredProgrammaticRequestDoesNotOpenAfterControlBecomesDisabled() async { - var didOpen = false - let presenter = StableMenuPresenter { _, _, _ in - XCTFail("Disabled control must not present a menu") + func testDeferredProgrammaticRequestUsesLatestPreparationClosures() async { + var oldOpenCount = 0 + var oldItemCount = 0 + var newOpenCount = 0 + var newItemCount = 0 + var presentedTitle: String? + let presenter = StableMenuPresenter { menu, _, _ in + presentedTitle = menu.items.first?.title } let fixture = makeAttachedAnchor() - presenter.update( - anchorView: fixture.anchor, - isEnabled: true, - programmaticPresentationID: "current", - isProgrammaticPresentationAllowed: true + update( + presenter, + anchor: fixture.anchor, + onOpen: { oldOpenCount += 1 }, + items: { + oldItemCount += 1 + return [.action("Old") {}] + } ) - enqueueProgrammaticPresentation(presenter, expectedID: "current") { didOpen = true } - presenter.update( - anchorView: fixture.anchor, + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "current") + update( + presenter, + anchor: fixture.anchor, + onOpen: { newOpenCount += 1 }, + items: { + newItemCount += 1 + return [.action("New") {}] + } + ) + await drainMainQueue() + + XCTAssertEqual(oldOpenCount, 0) + XCTAssertEqual(oldItemCount, 0) + XCTAssertEqual(newOpenCount, 1) + XCTAssertEqual(newItemCount, 1) + XCTAssertEqual(presentedTitle, "New") + } + + func testDeferredProgrammaticRequestDoesNotOpenWhenSwiftUIControlBecomesDisabled() async { + let counters = PreparationCounters() + let presenter = rejectingPresenter(message: "Disabled control must not present a menu") + let fixture = makeAttachedAnchor() + update(presenter, anchor: fixture.anchor, counters: counters) + + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "current") + update( + presenter, + anchor: fixture.anchor, isEnabled: false, - programmaticPresentationID: "current", - isProgrammaticPresentationAllowed: false + isProgrammaticPresentationAllowed: true, + counters: counters ) await drainMainQueue() - XCTAssertFalse(didOpen) + assertPreparationWasSkipped(counters) } - func testDeferredProgrammaticRequestDoesNotOpenAfterAnchorDetaches() async { - var didOpen = false - let presenter = StableMenuPresenter { _, _, _ in - XCTFail("Detached control must not present a menu") - } + func testDeferredProgrammaticRequestDoesNotOpenWhenLiveApplicabilityBecomesFalse() async { + let counters = PreparationCounters() + let presenter = rejectingPresenter(message: "Inapplicable request must not present a menu") let fixture = makeAttachedAnchor() - presenter.update( - anchorView: fixture.anchor, + update(presenter, anchor: fixture.anchor, counters: counters) + + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "current") + update( + presenter, + anchor: fixture.anchor, isEnabled: true, - programmaticPresentationID: "current", - isProgrammaticPresentationAllowed: true + isProgrammaticPresentationAllowed: false, + counters: counters ) + await drainMainQueue() + + assertPreparationWasSkipped(counters) + } + + func testDeferredProgrammaticRequestDoesNotOpenAfterAnchorDetaches() async { + let counters = PreparationCounters() + let presenter = rejectingPresenter(message: "Detached control must not present a menu") + let fixture = makeAttachedAnchor() + update(presenter, anchor: fixture.anchor, counters: counters) - enqueueProgrammaticPresentation(presenter, expectedID: "current") { didOpen = true } + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "current") fixture.anchor.removeFromSuperview() await drainMainQueue() - XCTAssertFalse(didOpen) + assertPreparationWasSkipped(counters) } func testDeferredProgrammaticRequestDoesNotOpenForStalePresentationIdentity() async { - var didOpen = false - let presenter = StableMenuPresenter { _, _, _ in - XCTFail("Stale request must not present a menu") - } + let counters = PreparationCounters() + let presenter = rejectingPresenter(message: "Stale request must not present a menu") let fixture = makeAttachedAnchor() - presenter.update( - anchorView: fixture.anchor, - isEnabled: true, + update( + presenter, + anchor: fixture.anchor, programmaticPresentationID: "old-tab-or-provider", - isProgrammaticPresentationAllowed: true + counters: counters ) - enqueueProgrammaticPresentation(presenter, expectedID: "old-tab-or-provider") { didOpen = true } - presenter.update( - anchorView: fixture.anchor, - isEnabled: true, + presenter.enqueueProgrammaticPresentation(expectedPresentationID: "old-tab-or-provider") + update( + presenter, + anchor: fixture.anchor, programmaticPresentationID: "new-tab-or-provider", - isProgrammaticPresentationAllowed: true + counters: counters ) await drainMainQueue() - XCTAssertFalse(didOpen) + assertPreparationWasSkipped(counters) } func testOrdinaryClickPresentationDoesNotRequireProgrammaticPermission() { @@ -103,11 +151,12 @@ final class StableMenuPresenterTests: XCTestCase { didPresent = true } let fixture = makeAttachedAnchor() - presenter.update( - anchorView: fixture.anchor, - isEnabled: true, - programmaticPresentationID: "current", - isProgrammaticPresentationAllowed: false + update( + presenter, + anchor: fixture.anchor, + isProgrammaticPresentationAllowed: false, + onOpen: {}, + items: { [.action("Current") {}] } ) presenter.present( @@ -121,18 +170,58 @@ final class StableMenuPresenterTests: XCTestCase { XCTAssertTrue(didPresent) } - private func enqueueProgrammaticPresentation( + private func update( _ presenter: StableMenuPresenter, - expectedID: AnyHashable, - onOpen: @escaping () -> Void + anchor: NSView, + isEnabled: Bool = true, + programmaticPresentationID: AnyHashable = "current", + isProgrammaticPresentationAllowed: Bool = true, + onOpen: @escaping () -> Void, + items: @escaping () -> [StableMenuItem] ) { - presenter.enqueueProgrammaticPresentation( - expectedPresentationID: expectedID, + presenter.update( + anchorView: anchor, + isEnabled: isEnabled, + programmaticPresentationID: programmaticPresentationID, + isProgrammaticPresentationAllowed: isProgrammaticPresentationAllowed, onOpen: onOpen, - items: { [.action("Item") {}] } + items: items ) } + private func update( + _ presenter: StableMenuPresenter, + anchor: NSView, + isEnabled: Bool = true, + programmaticPresentationID: AnyHashable = "current", + isProgrammaticPresentationAllowed: Bool = true, + counters: PreparationCounters + ) { + update( + presenter, + anchor: anchor, + isEnabled: isEnabled, + programmaticPresentationID: programmaticPresentationID, + isProgrammaticPresentationAllowed: isProgrammaticPresentationAllowed, + onOpen: { counters.openCount += 1 }, + items: { + counters.itemCount += 1 + return [.action("Item") {}] + } + ) + } + + private func rejectingPresenter(message: String) -> StableMenuPresenter { + StableMenuPresenter { _, _, _ in + XCTFail(message) + } + } + + private func assertPreparationWasSkipped(_ counters: PreparationCounters) { + XCTAssertEqual(counters.openCount, 0) + XCTAssertEqual(counters.itemCount, 0) + } + private func drainMainQueue() async { await withCheckedContinuation { continuation in DispatchQueue.main.async { @@ -153,3 +242,9 @@ final class StableMenuPresenterTests: XCTestCase { return (window, anchor) } } + +@MainActor +private final class PreparationCounters { + var openCount = 0 + var itemCount = 0 +}