From e91dab3f50bd6c4141debebc55474a091340271f Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:57:33 -0400 Subject: [PATCH] Disable Apple Intelligence in engine pickers when it is unavailable Onboarding and the power-profile pickers already refuse Apple Intelligence when FoundationModelAvailabilityService reports it unavailable, but the Settings Engine picker and the menu bar Engine picker listed every engine unconditionally, so a user could switch to an engine the Mac cannot run. Add SuggestionEngineSelectionPolicy (pure, Support/Settings) as the single rule for which engines are selectable and how each is labelled. Both pickers now show an unavailable engine greyed out with an "(Unavailable)" suffix via selectionDisabled, and their bindings refuse to persist it. A previously stored selection is still honored as before; the existing warning callout and engine error continue to explain it. Co-Authored-By: Claude Opus 5.5 --- Cotabby.xcodeproj/project.pbxproj | 10 +++++ .../SuggestionEngineSelectionPolicy.swift | 43 ++++++++++++++++++ Cotabby/UI/MenuBar/MenuBarView.swift | 21 ++++++++- .../EngineAndModelPaneView+Actions.swift | 14 +++++- .../Panes/Engine/EngineAndModelPaneView.swift | 12 ++++- ...SuggestionEngineSelectionPolicyTests.swift | 44 +++++++++++++++++++ 6 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 Cotabby/Support/Settings/SuggestionEngineSelectionPolicy.swift create mode 100644 CotabbyTests/Support/Settings/SuggestionEngineSelectionPolicyTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 59550455..ddd90ac8 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -290,6 +290,7 @@ 4882DF865737ECDC4925F44B /* GeneralPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */; }; 48A2F371756723299597F5A1 /* TypoGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCE0E0C38A776383B11809 /* TypoGate.swift */; }; 494DA4B28C173F2B3E18DBF9 /* EmojiPickerControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD02052E51AFE56BECB92E4 /* EmojiPickerControllerTests.swift */; }; + 49EFEFB496CEFAEAEEF1AEBB /* SuggestionEngineSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83256754ED5DD82BD749D6CE /* SuggestionEngineSelectionPolicy.swift */; }; 4A460D26B773DC213B0E6471 /* DeviceInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D5E77EB08030489DAFA4DC /* DeviceInfoTests.swift */; }; 4A69EB9EEA4620D2EC35B9B2 /* ClipboardContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B70455CA75A08FAD0F7409 /* ClipboardContextProvider.swift */; }; 4AC255BE2D0CCC67B8882C7A /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */; }; @@ -485,6 +486,7 @@ 7C56674EF8DA8869D5116EE6 /* GhostSpaceBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD9262617968D8D1EF1EA45 /* GhostSpaceBoundaryTests.swift */; }; 7C5BD8FDACC491EF62665FB2 /* TerminalAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DC2CCFDB2BF1F1E1375620 /* TerminalAppDetector.swift */; }; 7CD9B73CE933F15B490B3605 /* SettingsSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DB1F528070F8F9108720DB /* SettingsSearchResultRow.swift */; }; + 7D4D95E3626A1263DF8A0AC4 /* SuggestionEngineSelectionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B95D6456663C72DE3A6AF1 /* SuggestionEngineSelectionPolicyTests.swift */; }; 7D87C0AEF85FA0653C9C6031 /* PerformanceMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA991392A72658B758551BBA /* PerformanceMetricsStoreTests.swift */; }; 7DE4F91CADF4E65BCCF4DB17 /* SettingsRowLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44768629365FAD1A66DA3A51 /* SettingsRowLabel.swift */; }; 7E11BEAD32E9FF170C92000A /* HuggingFaceModelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C696204AAEBA357129F77646 /* HuggingFaceModelBrowserView.swift */; }; @@ -623,6 +625,7 @@ A262EF1BD871C12840DA75F2 /* SentenceBoundaryClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FC72BD286ABA5B3CEF82F7 /* SentenceBoundaryClassifier.swift */; }; A275EDDC0B43865C587C33AA /* TrailingDuplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD851386E85D3ED0A1F17E47 /* TrailingDuplicationFilter.swift */; }; A29869C9E187C7EEF756E08F /* CaretTokenPositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A7F2B20877E19BD2B27D1E /* CaretTokenPositionTests.swift */; }; + A2993441AFFF43C25193BC98 /* SuggestionEngineSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83256754ED5DD82BD749D6CE /* SuggestionEngineSelectionPolicy.swift */; }; A2E77B6BE41060407B2C0508 /* SwiftLog-Apache-2.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 19B257134CCCD0D4370E5287 /* SwiftLog-Apache-2.0.txt */; }; A2FCC90388ABD94CB3C9F9AA /* SuggestionOverlayAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4850AE68E84879254CBFEDE6 /* SuggestionOverlayAcceptanceTests.swift */; }; A30F28CB21A2DDE9E44FC10A /* BundleVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A951C04DF783B1F1EFDD1E5 /* BundleVersion.swift */; }; @@ -1311,6 +1314,7 @@ 81C4E4D11C9BE3B53F79FD36 /* TypingPredictionCandidateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingPredictionCandidateTests.swift; sourceTree = ""; }; 81D7F428477976398CFC5CCF /* DecodeStopPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeStopPolicyTests.swift; sourceTree = ""; }; 82420F9505E69AE2ADE9F583 /* BlockBreakAlignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockBreakAlignment.swift; sourceTree = ""; }; + 83256754ED5DD82BD749D6CE /* SuggestionEngineSelectionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineSelectionPolicy.swift; sourceTree = ""; }; 83457CCF1A50CE83428C363D /* Aria2OutputParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aria2OutputParser.swift; sourceTree = ""; }; 8368940C6C15CC3F932B8DFC /* ContextLivePreviewField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextLivePreviewField.swift; sourceTree = ""; }; 846A1C3328B3B1CB42E2E46B /* EmojiUsageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageStore.swift; sourceTree = ""; }; @@ -1584,6 +1588,7 @@ F0A61DC16ED76D694A59BF14 /* SuggestionSessionTypingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionTypingTests.swift; sourceTree = ""; }; F155556C751FB074B31CCC64 /* WelcomeHeroDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeHeroDemo.swift; sourceTree = ""; }; F1A4C5DEA32868F99E953D99 /* CompletionSeamGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSeamGuard.swift; sourceTree = ""; }; + F1B95D6456663C72DE3A6AF1 /* SuggestionEngineSelectionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineSelectionPolicyTests.swift; sourceTree = ""; }; F1E69CFA319C5F9E4C30F699 /* SettingsQuickLinkCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsQuickLinkCard.swift; sourceTree = ""; }; F23A711EA8EF13B480FF34DE /* HostBaselineCalibratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostBaselineCalibratorTests.swift; sourceTree = ""; }; F26F50467C83B202FB4BAC2A /* WelcomePersonalizeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePersonalizeStepView.swift; sourceTree = ""; }; @@ -1888,6 +1893,7 @@ 6F49C55FFF4352A6763BAE1A /* PerDomainDisableSettings.swift */, 79AAAB1C46A0E5A9C6FC7D3D /* SettingsAttentionEvaluator.swift */, 57B29DAC600626783649475A /* SettingsSearchRanker.swift */, + 83256754ED5DD82BD749D6CE /* SuggestionEngineSelectionPolicy.swift */, 8AB17696CE79982039061F29 /* SuggestionSettingsStore.swift */, ); path = Settings; @@ -2015,6 +2021,7 @@ 9227DE16CA5CE2E085C1A891 /* PerAppShortcutOverrideStoreTests.swift */, 6E53B16C1E0CE667E9666C71 /* SettingsAttentionEvaluatorTests.swift */, 95C62A4403E4815004C6FCB8 /* SettingsSearchRankerTests.swift */, + F1B95D6456663C72DE3A6AF1 /* SuggestionEngineSelectionPolicyTests.swift */, 35C0B587D81D87ACB952C95E /* SuggestionSettingsStoreTests.swift */, ); path = Settings; @@ -4089,6 +4096,7 @@ 1996768B9B6FAE54583C2ABE /* SuggestionDismissalMemory.swift in Sources */, 468477E87700B5D18BCD7A7A /* SuggestionEngineModels.swift in Sources */, 9973763A9F4EA4D8B4AE59EB /* SuggestionEngineRouter.swift in Sources */, + 49EFEFB496CEFAEAEEF1AEBB /* SuggestionEngineSelectionPolicy.swift in Sources */, CCA8847AD15933EC1EB2C238 /* SuggestionFadeInPolicy.swift in Sources */, CF2EADEEEF5AA63FB9B9EA8E /* SuggestionInserter.swift in Sources */, B413327721F83D9B5C8D4BCE /* SuggestionInteractionState.swift in Sources */, @@ -4415,6 +4423,7 @@ FECD5A1F9317317983B49D34 /* SuggestionDismissalMemory.swift in Sources */, CDDEF3C29AEACA986034C01C /* SuggestionEngineModels.swift in Sources */, 52518CF0760DFEE9AF7C786C /* SuggestionEngineRouter.swift in Sources */, + A2993441AFFF43C25193BC98 /* SuggestionEngineSelectionPolicy.swift in Sources */, ADB10BCA76295920FE4A9FE1 /* SuggestionFadeInPolicy.swift in Sources */, 2FC40D4BFDD05C2401D7A5E9 /* SuggestionInserter.swift in Sources */, D795377F4F24F49740808960 /* SuggestionInteractionState.swift in Sources */, @@ -4689,6 +4698,7 @@ 85EDFFAB94CB8577CDBAF49B /* SuggestionDismissalMemoryTests.swift in Sources */, 3A3B49975BCF1F648F8C1782 /* SuggestionEngineModelsTests.swift in Sources */, F76ECB9663E46AA806E2C7DE /* SuggestionEngineRouterTests.swift in Sources */, + 7D4D95E3626A1263DF8A0AC4 /* SuggestionEngineSelectionPolicyTests.swift in Sources */, 66F5E2CB7F4ADFA7309E7892 /* SuggestionFadeInPolicyTests.swift in Sources */, 16A1C0C9B93983231FAD809B /* SuggestionFocusFreshnessTests.swift in Sources */, 66F8D175E7F36D269C6E0659 /* SuggestionInsertionChunkTests.swift in Sources */, diff --git a/Cotabby/Support/Settings/SuggestionEngineSelectionPolicy.swift b/Cotabby/Support/Settings/SuggestionEngineSelectionPolicy.swift new file mode 100644 index 00000000..dd85beac --- /dev/null +++ b/Cotabby/Support/Settings/SuggestionEngineSelectionPolicy.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Decides which suggestion engines a user may pick from an engine picker, and how each option is +/// labelled, given what this Mac can currently run. +/// +/// Why this exists as its own type: onboarding (`WelcomeTemplateStepView`) and the power-profile +/// pickers already refuse Apple Intelligence when `FoundationModelAvailabilityService` reports it +/// unavailable, but the Settings and menu bar engine pickers listed every `SuggestionEngineKind` +/// unconditionally. Putting the rule in one pure `Support/` policy lets every picker share the same +/// answer and lets tests pin it without SwiftUI or a real `SystemLanguageModel`. +/// +/// Scope: this gates *new* selections only. A previously persisted Apple Intelligence selection is +/// still honored as stored; the Settings callout and the engine's own `unavailable` error explain +/// why nothing is generated, and the choice comes back to life if Apple Intelligence becomes +/// available later (for example after its model finishes downloading). +enum SuggestionEngineSelectionPolicy { + /// Whether `engine` may be chosen right now. Only Apple Intelligence depends on runtime + /// availability; Open Source and the local endpoint are always selectable because their own + /// panes guide the user through downloading a model or configuring a server. + static func isSelectable( + _ engine: SuggestionEngineKind, + foundationModelAvailable: Bool + ) -> Bool { + switch engine { + case .appleIntelligence: + return foundationModelAvailable + case .llamaOpenSource, .openAICompatible: + return true + } + } + + /// The picker row text. Unselectable engines carry an "(Unavailable)" suffix so a greyed-out + /// menu item still explains itself; the detailed reason stays in the pane callout. + static func pickerLabel( + for engine: SuggestionEngineKind, + foundationModelAvailable: Bool + ) -> String { + guard isSelectable(engine, foundationModelAvailable: foundationModelAvailable) else { + return "\(engine.displayLabel) (Unavailable)" + } + return engine.displayLabel + } +} diff --git a/Cotabby/UI/MenuBar/MenuBarView.swift b/Cotabby/UI/MenuBar/MenuBarView.swift index a6a2b6f9..0db858a8 100644 --- a/Cotabby/UI/MenuBar/MenuBarView.swift +++ b/Cotabby/UI/MenuBar/MenuBarView.swift @@ -175,9 +175,17 @@ struct MenuBarView: View { Group { MenuBarPickerRow(title: "Engine") { Picker("Engine", selection: selectedEngineBinding) { + // Same gating as Settings and onboarding: an unavailable engine is shown + // greyed out with an "(Unavailable)" suffix and cannot be picked. ForEach(SuggestionEngineKind.allCases) { engine in - Text(engine.displayLabel) - .tag(engine) + Text( + SuggestionEngineSelectionPolicy.pickerLabel( + for: engine, + foundationModelAvailable: foundationModelAvailabilityService.isAvailable + ) + ) + .tag(engine) + .selectionDisabled(!isEngineSelectable(engine)) } } .labelsHidden() @@ -347,10 +355,19 @@ struct MenuBarView: View { ) } + private func isEngineSelectable(_ engine: SuggestionEngineKind) -> Bool { + SuggestionEngineSelectionPolicy.isSelectable( + engine, + foundationModelAvailable: foundationModelAvailabilityService.isAvailable + ) + } + private var selectedEngineBinding: Binding { Binding( get: { suggestionSettings.selectedEngine }, set: { engine in + // Never persist an engine this Mac cannot run, as a guard behind the disabled item. + guard isEngineSelectable(engine) else { return } // With power-based switching on, the active engine is owned by the current power // source's profile. Editing it here writes that profile (battery vs. plugged-in) // instead of `selectedEngine`, which the switcher would otherwise revert. The profile diff --git a/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+Actions.swift b/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+Actions.swift index ebc81522..adbda697 100644 --- a/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+Actions.swift +++ b/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView+Actions.swift @@ -42,7 +42,19 @@ extension EngineAndModelPaneView { var selectedEngineBinding: Binding { Binding( get: { suggestionSettings.selectedEngine }, - set: { suggestionSettings.selectEngine($0) } + set: { engine in + // Second line of defense behind the disabled menu item: never persist an engine the + // Mac cannot run, even if a future picker style ignores `selectionDisabled`. + guard isEngineSelectable(engine) else { return } + suggestionSettings.selectEngine(engine) + } + ) + } + + func isEngineSelectable(_ engine: SuggestionEngineKind) -> Bool { + SuggestionEngineSelectionPolicy.isSelectable( + engine, + foundationModelAvailable: foundationModelAvailabilityService.isAvailable ) } diff --git a/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView.swift b/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView.swift index abcabdcb..e937af5e 100644 --- a/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView.swift +++ b/Cotabby/UI/Settings/Panes/Engine/EngineAndModelPaneView.swift @@ -29,8 +29,18 @@ struct EngineAndModelPaneView: View { SettingsPaneScaffold(callout: callout) { Section("Engine") { Picker(selection: selectedEngineBinding) { + // Match onboarding: an engine this Mac cannot run stays listed (so a stored + // selection still renders) but is greyed out and labelled, never selectable. + // `selectionDisabled` (macOS 14+) disables just that menu item. ForEach(SuggestionEngineKind.allCases) { engine in - Text(engine.displayLabel).tag(engine) + Text( + SuggestionEngineSelectionPolicy.pickerLabel( + for: engine, + foundationModelAvailable: foundationModelAvailabilityService.isAvailable + ) + ) + .tag(engine) + .selectionDisabled(!isEngineSelectable(engine)) } } label: { SettingsRowLabel( diff --git a/CotabbyTests/Support/Settings/SuggestionEngineSelectionPolicyTests.swift b/CotabbyTests/Support/Settings/SuggestionEngineSelectionPolicyTests.swift new file mode 100644 index 00000000..9e928fe7 --- /dev/null +++ b/CotabbyTests/Support/Settings/SuggestionEngineSelectionPolicyTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import Cotabby + +/// Pins the engine-picker gating shared by Settings and the menu bar so Apple Intelligence can never +/// again be offered as a selectable engine on a Mac that cannot run it, while the other engines stay +/// selectable regardless of Apple Intelligence availability. +final class SuggestionEngineSelectionPolicyTests: XCTestCase { + func test_isSelectable_appleIntelligenceFollowsAvailability() { + XCTAssertTrue( + SuggestionEngineSelectionPolicy.isSelectable(.appleIntelligence, foundationModelAvailable: true) + ) + XCTAssertFalse( + SuggestionEngineSelectionPolicy.isSelectable(.appleIntelligence, foundationModelAvailable: false) + ) + } + + func test_isSelectable_otherEnginesIgnoreAppleAvailability() { + for engine in [SuggestionEngineKind.llamaOpenSource, .openAICompatible] { + for available in [true, false] { + XCTAssertTrue( + SuggestionEngineSelectionPolicy.isSelectable(engine, foundationModelAvailable: available), + "\(engine) available=\(available)" + ) + } + } + } + + func test_pickerLabel_marksOnlyUnselectableEngines() { + XCTAssertEqual( + SuggestionEngineSelectionPolicy.pickerLabel(for: .appleIntelligence, foundationModelAvailable: false), + "Apple Intelligence (Unavailable)" + ) + for engine in SuggestionEngineKind.allCases { + XCTAssertEqual( + SuggestionEngineSelectionPolicy.pickerLabel(for: engine, foundationModelAvailable: true), + engine.displayLabel + ) + } + XCTAssertEqual( + SuggestionEngineSelectionPolicy.pickerLabel(for: .llamaOpenSource, foundationModelAvailable: false), + SuggestionEngineKind.llamaOpenSource.displayLabel + ) + } +}